Merge remote-tracking branch 'origin/master' into codex/ddd

# Conflicts:
#	docs/technical/README.md
#	docs/technical/RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md
#	docs/technical/SPACETIMEDB_TABLE_CATALOG.md
#	scripts/generate-spacetime-bindings.mjs
#	server-rs/crates/api-server/src/app.rs
#	server-rs/crates/api-server/src/assets.rs
#	server-rs/crates/api-server/src/big_fish.rs
#	server-rs/crates/api-server/src/custom_world_ai.rs
#	server-rs/crates/api-server/src/llm.rs
#	server-rs/crates/api-server/src/main.rs
#	server-rs/crates/api-server/src/puzzle.rs
#	server-rs/crates/api-server/src/runtime_profile.rs
#	server-rs/crates/api-server/src/runtime_story/compat/ai.rs
#	server-rs/crates/api-server/src/runtime_story/compat/npc_actions.rs
#	server-rs/crates/api-server/src/runtime_story/compat/presentation.rs
#	server-rs/crates/api-server/src/runtime_story/compat/tests.rs
#	server-rs/crates/api-server/src/state.rs
#	server-rs/crates/module-auth/src/lib.rs
#	server-rs/crates/module-big-fish/src/lib.rs
#	server-rs/crates/module-custom-world/src/lib.rs
#	server-rs/crates/module-puzzle/src/lib.rs
#	server-rs/crates/module-runtime/src/lib.rs
#	server-rs/crates/spacetime-client/src/big_fish.rs
#	server-rs/crates/spacetime-client/src/lib.rs
#	server-rs/crates/spacetime-client/src/mapper.rs
#	server-rs/crates/spacetime-client/src/module_bindings/admin_disable_profile_redeem_code_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_redeem_code_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/advance_puzzle_next_level_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/append_ai_text_chunk_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/apply_chapter_progression_ledger_entry_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/attach_ai_result_reference_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/authorize_database_migration_operator_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/begin_story_session_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/big_fish_runtime_run_type.rs
#	server-rs/crates/spacetime-client/src/module_bindings/bind_asset_object_to_entity_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/cancel_ai_task_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/clear_platform_browse_history_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/compile_big_fish_draft_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/compile_custom_world_published_profile_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/compile_puzzle_agent_draft_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/complete_ai_stage_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/complete_ai_task_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/confirm_asset_object_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/consume_profile_wallet_points_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/continue_story_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/create_ai_task_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/create_battle_state_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/create_big_fish_session_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/create_custom_world_agent_session_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/create_profile_recharge_order_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/create_puzzle_agent_session_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/delete_big_fish_work_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/delete_custom_world_agent_session_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/delete_custom_world_profile_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/delete_puzzle_work_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/delete_runtime_snapshot_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/drag_puzzle_piece_or_group_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/execute_custom_world_agent_action_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/export_auth_store_snapshot_from_tables_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/export_database_migration_to_file_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/fail_ai_task_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/finalize_big_fish_agent_message_turn_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/finalize_custom_world_agent_message_turn_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/finalize_puzzle_agent_message_turn_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/generate_big_fish_asset_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_auth_store_snapshot_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_battle_state_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_big_fish_session_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_chapter_progression_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_card_detail_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_operation_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_session_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_gallery_detail_by_code_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_gallery_detail_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_library_detail_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_player_progression_or_default_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_profile_dashboard_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_profile_play_stats_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_profile_recharge_center_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_profile_referral_invite_center_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_agent_session_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_gallery_detail_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_run_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_work_detail_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_runtime_inventory_state_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_runtime_setting_or_default_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_runtime_snapshot_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_story_session_state_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/grant_player_progression_experience_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/import_auth_store_snapshot_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_from_file_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_incremental_from_file_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_asset_history_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_big_fish_works_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_custom_world_gallery_entries_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_custom_world_profiles_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_custom_world_works_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_platform_browse_history_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_profile_save_archives_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_profile_wallet_ledger_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_puzzle_gallery_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_puzzle_works_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/mod.rs
#	server-rs/crates/spacetime-client/src/module_bindings/publish_big_fish_game_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/publish_custom_world_profile_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/publish_custom_world_world_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/publish_puzzle_work_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/record_big_fish_play_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/redeem_profile_referral_invite_code_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/redeem_profile_reward_code_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/refund_profile_wallet_points_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/resolve_combat_action_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_battle_interaction_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_interaction_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_social_action_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/resolve_treasure_interaction_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/resume_profile_save_archive_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/revoke_database_migration_operator_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/save_puzzle_generated_images_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/select_puzzle_cover_image_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/start_puzzle_run_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/submit_big_fish_message_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/submit_custom_world_agent_message_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/submit_puzzle_agent_message_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/submit_puzzle_leaderboard_entry_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/swap_puzzle_pieces_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/unpublish_custom_world_profile_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/update_puzzle_work_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_auth_store_snapshot_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_chapter_progression_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_custom_world_agent_operation_progress_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_custom_world_profile_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_npc_state_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_platform_browse_history_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_runtime_setting_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_runtime_snapshot_and_return_procedure.rs
#	server-rs/crates/spacetime-module/src/auth/procedures.rs
#	server-rs/crates/spacetime-module/src/custom_world/mod.rs
#	server-rs/crates/spacetime-module/src/lib.rs
#	server-rs/crates/spacetime-module/src/migration.rs
#	server-rs/crates/spacetime-module/src/puzzle.rs
#	server-rs/crates/spacetime-module/src/runtime/profile.rs
#	src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
#	src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx
#	src/services/aiService.ts
#	src/services/puzzle-runtime/puzzleRuntimeClient.ts
This commit is contained in:
kdletters
2026-05-02 03:35:59 +08:00
513 changed files with 52813 additions and 6013 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -88,13 +88,34 @@ vi.mock('./rpg-runtime-shell', () => ({
session,
chrome,
}: {
session: { gameState: { currentScenePreset?: { name?: string } | null } };
session: {
gameState: {
currentScenePreset?: { id?: string; name?: string } | null;
playerCharacter?: { name?: string } | null;
runtimeSessionId?: string | null;
runtimeMode?: string;
runtimePersistenceDisabled?: boolean;
};
currentStory?: { text?: string } | null;
};
chrome?: { hidePlayerLevelBadge?: boolean };
}) => (
<div>
<div></div>
{chrome?.hidePlayerLevelBadge ? <div></div> : null}
<div>{session.gameState.currentScenePreset?.name ?? '未进入场景'}</div>
<div>{session.gameState.currentScenePreset?.id ?? '未进入场景ID'}</div>
<div>
{session.gameState.playerCharacter ? '已选择预览角色' : '未选择角色'}
</div>
<div>{session.gameState.runtimeSessionId ?? '未设置预览会话'}</div>
<div>{session.gameState.runtimeMode ?? '未设置运行模式'}</div>
<div>
{session.gameState.runtimePersistenceDisabled
? '预览禁用持久化'
: '预览允许持久化'}
</div>
<div>{session.currentStory?.text ?? '未生成当前故事'}</div>
</div>
),
}));
@@ -102,6 +123,30 @@ vi.mock('./rpg-runtime-shell', () => ({
vi.mock('./asset-studio/characterAssetWorkflowPersistence', () => ({
fetchCharacterWorkflowCache: vi.fn().mockResolvedValue({ cache: null }),
saveCharacterWorkflowCache: vi.fn().mockResolvedValue(undefined),
resolveCharacterRoleAssetWorkflow: vi.fn(({ role }) =>
Promise.resolve({
ok: true,
cache: null,
workflow: {
role,
defaultPromptBundle: {
visualPromptText: '',
animationPromptText: '',
scenePromptText: '',
},
visualPromptText: '',
animationPromptText: '',
animationPromptTextByKey: {},
visualDrafts: [],
selectedVisualDraftId: '',
selectedAnimation: 'idle',
},
}),
),
putCharacterRoleAssetWorkflow: vi.fn().mockResolvedValue({
ok: true,
cache: null,
}),
generateCharacterVisualCandidates: vi.fn(),
publishCharacterVisualAsset: vi.fn(),
generateCharacterAnimationDraft: vi.fn(),
@@ -1312,6 +1357,13 @@ test('场景幕预览会打开当前幕运行时面板', async () => {
expect(screen.getAllByText('沉钟栈桥').length).toBeGreaterThan(0);
expect(screen.getByText('隐藏等级徽标')).toBeTruthy();
expect(screen.getByText('已选择预览角色')).toBeTruthy();
expect(screen.getByText('runtime-scene-act-preview')).toBeTruthy();
expect(screen.getByText('landmark-1')).toBeTruthy();
expect(screen.getByText('play')).toBeTruthy();
expect(screen.getByText('预览禁用持久化')).toBeTruthy();
expect(screen.getByText(//u)).toBeTruthy();
expect(screen.queryByText('正在载入这一幕的游戏流程...')).toBeNull();
await user.click(screen.getByRole('button', { name: '结束预览' }));

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,15 +16,18 @@ const baseUser: AuthUser = {
id: 'user-1',
username: 'tester',
displayName: '138****8000',
avatarUrl: null,
publicUserCode: 'user-tester',
phoneNumberMasked: '138****8000',
loginMethod: 'phone',
bindingStatus: 'active',
wechatBound: true,
createdAt: new Date().toISOString(),
};
function renderAccountModal(overrides?: {
user?: AuthUser;
entryMode?: 'settings' | 'account';
riskBlocks?: AuthRiskBlockSummary[];
sessions?: AuthSessionSummary[];
auditLogs?: AuthAuditLogEntry[];
@@ -40,6 +43,7 @@ function renderAccountModal(overrides?: {
<AccountModal
user={overrides?.user ?? baseUser}
isOpen
entryMode={overrides?.entryMode ?? 'settings'}
initialSection={overrides?.initialSection ?? null}
platformTheme="light"
riskBlocks={overrides?.riskBlocks ?? []}
@@ -90,6 +94,21 @@ test('settings header uses a generic title instead of the phone number', () => {
expect(screen.queryByRole('button', { name: '退出全部设备' })).toBeNull();
});
test('direct account entry does not render the settings shell as another dialog', () => {
renderAccountModal({ entryMode: 'account' });
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
expect(accountDialog).toBeTruthy();
expect(screen.queryByRole('dialog', { name: '设置与账号安全' })).toBeNull();
expect(screen.queryByText('设置与账号安全')).toBeNull();
expect(
within(accountDialog).getByRole('button', { name: '关闭' }),
).toBeTruthy();
expect(
within(accountDialog).queryByRole('button', { name: '返回' }),
).toBeNull();
});
test('account actions open in independent panels instead of inline expansion', async () => {
const user = userEvent.setup();
@@ -130,9 +149,9 @@ test('nested settings panels keep back navigation without an extra close action'
expect(
within(accountDialog).getByRole('button', { name: '返回' }),
).toBeTruthy();
expect(
accountHeader?.lastElementChild?.textContent?.includes('返回'),
).toBe(true);
expect(accountHeader?.lastElementChild?.textContent?.includes('返回')).toBe(
true,
);
expect(
within(accountDialog).queryByRole('button', { name: '关闭' }),
).toBeNull();

View File

@@ -20,6 +20,7 @@ import { CaptchaChallengeField } from './CaptchaChallengeField';
type AccountModalProps = {
user: AuthUser;
isOpen: boolean;
entryMode?: 'settings' | 'account';
initialSection?: PlatformSettingsSection | null;
platformTheme: PlatformTheme;
riskBlocks: AuthRiskBlockSummary[];
@@ -159,6 +160,7 @@ function OverlayPanel({
title,
description,
action,
standalone = false,
onBack,
onClose,
children,
@@ -167,64 +169,73 @@ function OverlayPanel({
title: string;
description?: string;
action?: ReactNode;
standalone?: boolean;
onBack?: () => void;
onClose: () => void;
children: ReactNode;
}) {
const panel = (
<div
className="platform-auth-card flex max-h-full w-full min-h-0 flex-col overflow-hidden rounded-[28px] p-5 sm:max-w-3xl sm:p-6"
role="dialog"
aria-modal="true"
aria-label={title}
style={{ maxHeight: ACCOUNT_MODAL_MAX_HEIGHT }}
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="text-xs uppercase tracking-[0.28em] text-[var(--platform-cool-text)]">
{eyebrow}
</div>
<div className="mt-2 text-2xl font-semibold text-[var(--platform-text-strong)]">
{title}
</div>
{description ? (
<div className="mt-2 text-sm text-[var(--platform-text-base)]">
{description}
</div>
) : null}
</div>
<div className="flex items-center gap-2">
{action}
{onBack ? (
<button
type="button"
autoFocus
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs"
onClick={onBack}
>
</button>
) : (
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs"
onClick={onClose}
>
</button>
)}
</div>
</div>
<div className="mt-5 min-h-0 flex-1 overflow-y-auto overscroll-y-contain pr-1">
{children}
</div>
</div>
);
if (standalone) {
return panel;
}
return (
<div
className="absolute inset-0 z-10 flex items-end bg-black/20 backdrop-blur-[2px] sm:items-center sm:justify-center sm:p-4"
onClick={onBack ?? onClose}
>
<div
className="platform-auth-card flex max-h-full w-full min-h-0 flex-col overflow-hidden rounded-[28px] p-5 sm:max-w-3xl sm:p-6"
role="dialog"
aria-modal="true"
aria-label={title}
style={{ maxHeight: ACCOUNT_MODAL_MAX_HEIGHT }}
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="text-xs uppercase tracking-[0.28em] text-[var(--platform-cool-text)]">
{eyebrow}
</div>
<div className="mt-2 text-2xl font-semibold text-[var(--platform-text-strong)]">
{title}
</div>
{description ? (
<div className="mt-2 text-sm text-[var(--platform-text-base)]">
{description}
</div>
) : null}
</div>
<div className="flex items-center gap-2">
{action}
{onBack ? (
<button
type="button"
autoFocus
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs"
onClick={onBack}
>
</button>
) : (
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs"
onClick={onClose}
>
</button>
)}
</div>
</div>
<div className="mt-5 min-h-0 flex-1 overflow-y-auto overscroll-y-contain pr-1">
{children}
</div>
</div>
{panel}
</div>
);
}
@@ -266,6 +277,7 @@ function ThemeOptionCard({
export function AccountModal({
user,
isOpen,
entryMode = 'settings',
initialSection = null,
platformTheme,
riskBlocks,
@@ -314,6 +326,7 @@ export function AccountModal({
const sectionTriggerRef = useRef<HTMLButtonElement | null>(null);
const changePhoneTriggerRef = useRef<HTMLButtonElement | null>(null);
const passwordTriggerRef = useRef<HTMLButtonElement | null>(null);
const isDirectAccountMode = entryMode === 'account';
const focusAfterNextPaint = useCallback((element: HTMLElement | null) => {
if (!element) {
@@ -347,7 +360,11 @@ export function AccountModal({
return;
}
setActiveSection(normalizeSettingsSection(initialSection));
setActiveSection(
isDirectAccountMode
? 'account'
: normalizeSettingsSection(initialSection),
);
setIsChangePhonePanelOpen(false);
setIsPasswordPanelOpen(false);
setAccountNotice('');
@@ -356,7 +373,13 @@ export function AccountModal({
passwordTriggerRef.current = null;
resetChangePhoneDraft();
resetPasswordDraft();
}, [initialSection, isOpen, resetChangePhoneDraft, resetPasswordDraft]);
}, [
initialSection,
isDirectAccountMode,
isOpen,
resetChangePhoneDraft,
resetPasswordDraft,
]);
useEffect(() => {
const settingsHome = settingsHomeRef.current;
@@ -446,47 +469,55 @@ export function AccountModal({
onClick={onClose}
>
<div
className="platform-auth-card relative flex h-[min(100%,calc(100vh-2rem))] w-full max-w-5xl min-h-0 flex-col overflow-hidden rounded-[28px] p-5 sm:p-6"
role="dialog"
aria-modal="true"
aria-label="设置与账号安全"
className={
isDirectAccountMode
? 'relative flex max-h-full w-full max-w-3xl min-h-0 flex-col overflow-hidden'
: 'platform-auth-card relative flex h-[min(100%,calc(100vh-2rem))] w-full max-w-5xl min-h-0 flex-col overflow-hidden rounded-[28px] p-5 sm:p-6'
}
role={isDirectAccountMode ? undefined : 'dialog'}
aria-modal={isDirectAccountMode ? undefined : true}
aria-label={isDirectAccountMode ? undefined : '设置与账号安全'}
style={{ maxHeight: ACCOUNT_MODAL_MAX_HEIGHT }}
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-start justify-between gap-4">
<div>
<div className="text-2xl font-semibold text-[var(--platform-text-strong)]">
{!isDirectAccountMode ? (
<div className="flex items-start justify-between gap-4">
<div>
<div className="text-2xl font-semibold text-[var(--platform-text-strong)]">
</div>
</div>
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs"
onClick={onClose}
>
</button>
</div>
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs"
onClick={onClose}
>
</button>
</div>
) : null}
<div className="mt-5 min-h-0 flex-1 overflow-y-auto overscroll-y-contain pr-1">
<div ref={settingsHomeRef} className="flex min-h-0 flex-col gap-4">
<div className="grid gap-3 sm:grid-cols-2">
{SETTINGS_SECTIONS.map((section) => (
<SettingsEntryCard
key={section.id}
label={section.label}
detail={section.detail}
summary={sectionSummaries[section.id]}
onClick={(trigger) => {
sectionTriggerRef.current = trigger;
setAccountNotice('');
setActiveSection(section.id);
}}
/>
))}
{!isDirectAccountMode ? (
<div className="mt-5 min-h-0 flex-1 overflow-y-auto overscroll-y-contain pr-1">
<div ref={settingsHomeRef} className="flex min-h-0 flex-col gap-4">
<div className="grid gap-3 sm:grid-cols-2">
{SETTINGS_SECTIONS.map((section) => (
<SettingsEntryCard
key={section.id}
label={section.label}
detail={section.detail}
summary={sectionSummaries[section.id]}
onClick={(trigger) => {
sectionTriggerRef.current = trigger;
setAccountNotice('');
setActiveSection(section.id);
}}
/>
))}
</div>
</div>
</div>
</div>
) : null}
{activeSection === 'appearance' ? (
<OverlayPanel
@@ -538,7 +569,8 @@ export function AccountModal({
eyebrow="身份信息"
title="账号信息"
description="统一查看身份、安全状态、登录设备与最近操作。"
onBack={closeSectionPanel}
standalone={isDirectAccountMode}
onBack={isDirectAccountMode ? undefined : closeSectionPanel}
onClose={onClose}
>
<div className="flex min-h-0 flex-col gap-4">
@@ -671,7 +703,10 @@ export function AccountModal({
<span>{block.title}</span>
<span className="text-xs">
{' '}
{Math.max(1, Math.ceil(block.remainingSeconds / 60))}{' '}
{Math.max(
1,
Math.ceil(block.remainingSeconds / 60),
)}{' '}
</span>
</div>
@@ -965,7 +1000,9 @@ export function AccountModal({
type="password"
autoComplete="current-password"
placeholder="首次设置可留空"
onChange={(event) => setCurrentPassword(event.target.value)}
onChange={(event) =>
setCurrentPassword(event.target.value)
}
/>
</label>
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">

View File

@@ -18,6 +18,7 @@ const authMocks = vi.hoisted(() => ({
loginWithPhoneCode: vi.fn(),
logoutAllAuthSessions: vi.fn(),
logoutAuthUser: vi.fn(),
redeemRegistrationInviteCode: vi.fn(),
resetPassword: vi.fn(),
sendPhoneLoginCode: vi.fn(),
startWechatLogin: vi.fn(),
@@ -46,6 +47,7 @@ vi.mock('../../services/authService', () => ({
loginWithPhoneCode: authMocks.loginWithPhoneCode,
logoutAllAuthSessions: authMocks.logoutAllAuthSessions,
logoutAuthUser: authMocks.logoutAuthUser,
redeemRegistrationInviteCode: authMocks.redeemRegistrationInviteCode,
resetPassword: authMocks.resetPassword,
revokeAuthSession: vi.fn(),
sendPhoneLoginCode: authMocks.sendPhoneLoginCode,
@@ -78,26 +80,53 @@ const mockUser: AuthUser = {
id: 'user-1',
username: 'tester',
displayName: '测试玩家',
avatarUrl: null,
publicUserCode: 'user-tester',
phoneNumberMasked: '138****8000',
loginMethod: 'phone',
bindingStatus: 'active',
wechatBound: false,
createdAt: new Date().toISOString(),
};
beforeEach(() => {
vi.clearAllMocks();
window.history.replaceState(null, '', '/');
authMocks.consumeAuthCallbackResult.mockReturnValue(null);
authMocks.ensureStoredAccessToken.mockResolvedValue('jwt-existing-token');
authMocks.getCurrentAuthUser.mockResolvedValue({
user: null,
availableLoginMethods: ['phone'],
});
authMocks.loginWithPhoneCode.mockResolvedValue(mockUser);
authMocks.loginWithPhoneCode.mockResolvedValue({
token: 'jwt-phone',
user: mockUser,
created: false,
referral: null,
});
authMocks.authEntry.mockResolvedValue(mockUser);
authMocks.changePassword.mockResolvedValue(mockUser);
authMocks.logoutAllAuthSessions.mockResolvedValue(undefined);
authMocks.logoutAuthUser.mockResolvedValue(undefined);
authMocks.redeemRegistrationInviteCode.mockResolvedValue({
center: {
inviteCode: 'SY12345678',
inviteLinkPath: '/?inviteCode=SY12345678',
invitedCount: 1,
rewardedInviteCount: 1,
todayInviterRewardCount: 0,
todayInviterRewardRemaining: 3,
rewardPoints: 30,
hasRedeemedCode: true,
boundInviterUserId: 'user_inviter',
boundAt: '2026-05-01T00:00:00Z',
updatedAt: '2026-05-01T00:00:00Z',
},
inviteeRewardGranted: true,
inviterRewardGranted: true,
inviteeBalanceAfter: 30,
inviterBalanceAfter: 30,
});
authMocks.resetPassword.mockResolvedValue(mockUser);
authMocks.sendPhoneLoginCode.mockResolvedValue({
cooldownSeconds: 60,
@@ -238,12 +267,15 @@ test('auth gate keeps password entry available when login options are empty', as
const dialog = screen.getByRole('dialog', { name: '账号入口' });
expect(within(dialog).getByLabelText('密码')).toBeTruthy();
expect(within(dialog).queryByText('当前登录入口暂不可用。')).toBeNull();
expect(within(dialog).queryByText('读取登录方式失败')).toBeNull();
});
test('auth gate falls back to password entry when login options request fails', async () => {
const user = userEvent.setup();
authMocks.getAuthLoginOptions.mockRejectedValue(new Error('读取登录方式失败'));
authMocks.getAuthLoginOptions.mockRejectedValue(
new Error('读取登录方式失败'),
);
render(
<AuthGate>
@@ -294,6 +326,98 @@ test('auth gate opens a login modal for protected actions and resumes after logi
expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull();
});
test('auth gate hides register entry and opens invite modal for new sms account', async () => {
const user = userEvent.setup();
window.history.replaceState(null, '', '/?inviteCode=spring-2026');
authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: ['phone'],
});
authMocks.loginWithPhoneCode.mockResolvedValueOnce({
token: 'jwt-phone-new',
user: mockUser,
created: true,
referral: null,
});
render(
<AuthGate>
<div></div>
<ProtectedActionButton onAuthenticated={vi.fn()} />
</AuthGate>,
);
expect(await screen.findByText('公开内容')).toBeTruthy();
expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull();
await user.click(screen.getByRole('button', { name: '进入作品' }));
const dialog = await screen.findByRole('dialog', { name: '账号入口' });
expect(within(dialog).queryByRole('tab', { name: '注册' })).toBeNull();
expect(within(dialog).queryByLabelText('邀请码')).toBeNull();
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
await user.type(within(dialog).getByLabelText('验证码'), '123456');
await user.click(within(dialog).getByRole('button', { name: '登录' }));
await waitFor(() => {
expect(authMocks.loginWithPhoneCode).toHaveBeenCalledWith(
'13800000000',
'123456',
);
});
const inviteDialog = await screen.findByRole('dialog', {
name: '请填写邀请码',
});
expect(
(within(inviteDialog).getByLabelText('邀请码') as HTMLInputElement).value,
).toBe('SPRING2026');
expect(
within(inviteDialog).getByRole('button', { name: '提交' }),
).toBeTruthy();
await user.click(within(inviteDialog).getByRole('button', { name: '提交' }));
await waitFor(() => {
expect(authMocks.redeemRegistrationInviteCode).toHaveBeenCalledWith(
'SPRING2026',
);
});
});
test('registration invite modal can skip when invite code is empty', async () => {
const user = userEvent.setup();
authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: ['phone'],
});
authMocks.loginWithPhoneCode.mockResolvedValueOnce({
token: 'jwt-phone-new',
user: mockUser,
created: true,
referral: null,
});
render(
<AuthGate>
<ProtectedActionButton onAuthenticated={vi.fn()} />
</AuthGate>,
);
await user.click(await screen.findByRole('button', { name: '进入作品' }));
const dialog = screen.getByRole('dialog', { name: '账号入口' });
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
await user.type(within(dialog).getByLabelText('验证码'), '123456');
await user.click(within(dialog).getByRole('button', { name: '登录' }));
const inviteDialog = await screen.findByRole('dialog', {
name: '请填写邀请码',
});
await user.click(within(inviteDialog).getByRole('button', { name: '跳过' }));
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: '请填写邀请码' })).toBeNull();
});
expect(authMocks.redeemRegistrationInviteCode).not.toHaveBeenCalled();
});
test('auth state refresh keeps mounted platform content and local tab state', async () => {
const user = userEvent.setup();
authMocks.getCurrentAuthUser.mockResolvedValue({

View File

@@ -34,6 +34,7 @@ import {
loginWithPhoneCode,
logoutAllAuthSessions,
logoutAuthUser,
redeemRegistrationInviteCode,
resetPassword,
revokeAuthSession,
sendPhoneLoginCode,
@@ -44,6 +45,7 @@ import { AccountModal } from './AccountModal';
import { AuthUiContext, type PlatformSettingsSection } from './AuthUiContext';
import { BindPhoneScreen } from './BindPhoneScreen';
import { LoginScreen } from './LoginScreen';
import { RegistrationInviteModal } from './RegistrationInviteModal';
type AuthGateProps = {
children: ReactNode;
@@ -59,6 +61,14 @@ type AuthStatus =
const FALLBACK_LOGIN_METHODS: AuthLoginMethod[] = ['password'];
function readInviteCodeFromLocation(): string {
const params = new URLSearchParams(window.location.search || '');
return (params.get('inviteCode') || params.get('invite_code') || '')
.trim()
.replace(/[^0-9a-z]/gi, '')
.toUpperCase();
}
function normalizeAvailableLoginMethods(
methods: AuthLoginMethod[] | null | undefined,
): AuthLoginMethod[] {
@@ -83,7 +93,16 @@ export function AuthGate({ children }: AuthGateProps) {
const [bindingPhone, setBindingPhone] = useState(false);
const [wechatLoading, setWechatLoading] = useState(false);
const [showLoginModal, setShowLoginModal] = useState(false);
const [pendingInviteCode, setPendingInviteCode] = useState('');
const [showRegistrationInviteModal, setShowRegistrationInviteModal] =
useState(false);
const [submittingRegistrationInvite, setSubmittingRegistrationInvite] =
useState(false);
const [registrationInviteError, setRegistrationInviteError] = useState('');
const [showSettingsModal, setShowSettingsModal] = useState(false);
const [settingsEntryMode, setSettingsEntryMode] = useState<
'settings' | 'account'
>('settings');
const [initialSettingsSection, setInitialSettingsSection] =
useState<PlatformSettingsSection | null>(null);
const [sessions, setSessions] = useState<AuthSessionSummary[]>([]);
@@ -99,6 +118,7 @@ export function AuthGate({ children }: AuthGateProps) {
const [changePhoneCaptchaChallenge, setChangePhoneCaptchaChallenge] =
useState<AuthCaptchaChallenge | null>(null);
const pendingProtectedActionRef = useRef<(() => void) | null>(null);
const autoOpenedInviteCodeRef = useRef<string | null>(null);
const hasRenderedPlatformContentRef = useRef(false);
const canKeepPlatformContentMounted =
hasRenderedPlatformContentRef.current &&
@@ -125,7 +145,9 @@ export function AuthGate({ children }: AuthGateProps) {
setUser(null);
setStatus('unauthenticated');
setShowLoginModal(false);
setShowRegistrationInviteModal(false);
setShowSettingsModal(false);
setSettingsEntryMode('settings');
setInitialSettingsSection(null);
setSessions([]);
setAuditLogs([]);
@@ -133,6 +155,8 @@ export function AuthGate({ children }: AuthGateProps) {
setLoginCaptchaChallenge(null);
setBindCaptchaChallenge(null);
setChangePhoneCaptchaChallenge(null);
setPendingInviteCode('');
setRegistrationInviteError('');
setError('');
}, []);
@@ -169,6 +193,18 @@ export function AuthGate({ children }: AuthGateProps) {
setError('');
}, []);
const closeRegistrationInviteModal = useCallback(() => {
setShowRegistrationInviteModal(false);
setRegistrationInviteError('');
setPendingInviteCode('');
}, []);
const closeSettingsModal = useCallback(() => {
setShowSettingsModal(false);
setSettingsEntryMode('settings');
setInitialSettingsSection(null);
}, []);
const openLoginModal = useCallback(
(postLoginAction?: (() => void) | null) => {
if (readyUser) {
@@ -192,6 +228,7 @@ export function AuthGate({ children }: AuthGateProps) {
const openSettingsModal = useCallback(
(section?: PlatformSettingsSection) => {
if (readyUser) {
setSettingsEntryMode('settings');
setInitialSettingsSection(section ?? null);
setShowSettingsModal(true);
return;
@@ -203,13 +240,36 @@ export function AuthGate({ children }: AuthGateProps) {
);
const openAccountModal = useCallback(() => {
openSettingsModal('account');
}, [openSettingsModal]);
if (readyUser) {
setSettingsEntryMode('account');
setInitialSettingsSection('account');
setShowSettingsModal(true);
return;
}
openLoginModal();
}, [openLoginModal, readyUser]);
useEffect(() => {
if (status !== 'unauthenticated' || readyUser || showLoginModal) {
return;
}
const inviteCode = readInviteCodeFromLocation();
if (!inviteCode) {
return;
}
if (autoOpenedInviteCodeRef.current === inviteCode) {
return;
}
autoOpenedInviteCodeRef.current = inviteCode;
setPendingInviteCode(inviteCode);
}, [readyUser, showLoginModal, status]);
useEffect(() => {
let isActive = true;
const hydrate = async () => {
const callbackResult = consumeAuthCallbackResult();
const loadLoginOptions = async () => {
const options = await getAuthLoginOptions();
if (!isActive) {
@@ -224,7 +284,7 @@ export function AuthGate({ children }: AuthGateProps) {
const resolveGuestFallback = async () => {
try {
const options = await loadLoginOptions();
await loadLoginOptions();
if (!isActive) {
return;
}
@@ -238,16 +298,13 @@ export function AuthGate({ children }: AuthGateProps) {
setAvailableLoginMethods(FALLBACK_LOGIN_METHODS);
setUser(null);
setError(
optionsError instanceof Error
? optionsError.message
: '读取登录方式失败,请稍后再试。',
);
// 中文注释:登录方式接口失败时按产品约定保留密码登录入口;
// 这里不展示接口读取错误,避免用户误以为登录本身不可用。
setError(callbackResult?.error ?? '');
setStatus('unauthenticated');
}
};
const callbackResult = consumeAuthCallbackResult();
if (callbackResult?.error && isActive) {
setError(callbackResult.error);
setShowLoginModal(true);
@@ -410,6 +467,7 @@ export function AuthGate({ children }: AuthGateProps) {
requireAuth,
openSettingsModal,
openAccountModal,
setCurrentUser: setUser,
logout: logoutCurrentSession,
musicVolume: settings.musicVolume,
setMusicVolume: settings.setMusicVolume,
@@ -554,6 +612,7 @@ export function AuthGate({ children }: AuthGateProps) {
<AccountModal
user={readyUser}
isOpen={showSettingsModal}
entryMode={settingsEntryMode}
initialSection={initialSettingsSection}
platformTheme={settings.platformTheme}
riskBlocks={riskBlocks}
@@ -565,7 +624,7 @@ export function AuthGate({ children }: AuthGateProps) {
isHydratingSettings={settings.isHydratingSettings}
isPersistingSettings={settings.isPersistingSettings}
settingsError={settings.settingsError}
onClose={() => setShowSettingsModal(false)}
onClose={closeSettingsModal}
onPlatformThemeChange={settings.setPlatformTheme}
onLogout={logoutCurrentSession}
onRefreshRiskBlocks={async () => {
@@ -711,10 +770,12 @@ export function AuthGate({ children }: AuthGateProps) {
setLoggingIn(true);
setError('');
try {
const nextUser = await loginWithPhoneCode(phone, code);
const response = await loginWithPhoneCode(phone, code);
setStoredLastLoginPhone(phone);
setLoginCaptchaChallenge(null);
activateReadyUser(nextUser);
setShowRegistrationInviteModal(response.created);
setRegistrationInviteError('');
activateReadyUser(response.user);
} catch (loginError) {
setError(
loginError instanceof Error
@@ -775,6 +836,30 @@ export function AuthGate({ children }: AuthGateProps) {
}
}}
/>
<RegistrationInviteModal
isOpen={showRegistrationInviteModal}
platformTheme={settings.platformTheme}
initialInviteCode={pendingInviteCode}
submitting={submittingRegistrationInvite}
error={registrationInviteError}
onClose={closeRegistrationInviteModal}
onSubmit={async (inviteCode) => {
setSubmittingRegistrationInvite(true);
setRegistrationInviteError('');
try {
await redeemRegistrationInviteCode(inviteCode);
closeRegistrationInviteModal();
} catch (inviteError) {
setRegistrationInviteError(
inviteError instanceof Error
? inviteError.message
: '填写邀请码失败,请稍后再试。',
);
} finally {
setSubmittingRegistrationInvite(false);
}
}}
/>
</div>
{children}
</div>

View File

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

View File

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

View File

@@ -196,9 +196,11 @@ export function LoginScreen({
/>
) : (
<div className="flex flex-col gap-5 px-5 py-5">
{phoneLoginEnabled && passwordLoginEnabled ? (
{phoneLoginEnabled ? (
<div
className="grid grid-cols-2 gap-2"
className={`grid gap-2 ${
passwordLoginEnabled ? 'grid-cols-2' : 'grid-cols-1'
}`}
role="tablist"
aria-label="登录方式"
>
@@ -208,12 +210,14 @@ export function LoginScreen({
>
</LoginTabButton>
<LoginTabButton
active={activeLoginTab === 'password'}
onClick={() => setActiveLoginTab('password')}
>
</LoginTabButton>
{passwordLoginEnabled ? (
<LoginTabButton
active={activeLoginTab === 'password'}
onClick={() => setActiveLoginTab('password')}
>
</LoginTabButton>
) : null}
</div>
) : null}

View File

@@ -0,0 +1,115 @@
import { X } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
type RegistrationInviteModalProps = {
isOpen: boolean;
platformTheme: PlatformTheme;
initialInviteCode: string;
submitting: boolean;
error: string;
onClose: () => void;
onSubmit: (inviteCode: string) => Promise<void>;
};
export function RegistrationInviteModal({
isOpen,
platformTheme,
initialInviteCode,
submitting,
error,
onClose,
onSubmit,
}: RegistrationInviteModalProps) {
const [inviteCode, setInviteCode] = useState(initialInviteCode);
const normalizedInviteCode = useMemo(
() =>
inviteCode
.trim()
.replace(/[^0-9a-z]/gi, '')
.toUpperCase(),
[inviteCode],
);
useEffect(() => {
if (!isOpen) {
return;
}
setInviteCode(initialInviteCode);
}, [initialInviteCode, isOpen]);
if (!isOpen) {
return null;
}
return (
<div
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[130] flex items-end justify-center px-3 py-4 text-[var(--platform-text-strong)] backdrop-blur-sm sm:items-center sm:p-4`}
onClick={onClose}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="registration-invite-dialog-title"
className="platform-auth-card w-full max-w-sm overflow-hidden rounded-[2rem]"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-5 py-4">
<div
id="registration-invite-dialog-title"
className="text-lg font-semibold text-[var(--platform-text-strong)]"
>
</div>
<button
type="button"
onClick={onClose}
className="platform-icon-button p-2"
aria-label="取消填写邀请码"
>
<X className="h-4 w-4" />
</button>
</div>
<form
className="flex flex-col gap-4 px-5 py-5"
onSubmit={(event) => {
event.preventDefault();
if (!normalizedInviteCode) {
onClose();
return;
}
void onSubmit(normalizedInviteCode);
}}
>
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
<span></span>
<input
className="platform-input"
autoComplete="off"
value={inviteCode}
onChange={(event) => setInviteCode(event.target.value)}
placeholder="邀请码"
/>
</label>
{error ? (
<div className="platform-banner platform-banner--danger text-sm">
{error}
</div>
) : null}
<button
type="submit"
disabled={submitting}
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
>
{submitting ? '提交中' : normalizedInviteCode ? '提交' : '跳过'}
</button>
</form>
</div>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { ArrowLeft, Paperclip, Send, Sparkles } from 'lucide-react';
import { ArrowLeft, ImagePlus, Paperclip, Send, Sparkles, X } from 'lucide-react';
import type { ChangeEvent } from 'react';
import { useEffect, useRef, useState } from 'react';
@@ -75,15 +75,21 @@ type CreationAgentWorkspaceProps = {
isBusy?: boolean;
error?: string | null;
quickActions?: CreationAgentQuickAction[];
referenceImagePreviewSrc?: string | null;
referenceImageLabel?: string | null;
referenceImageError?: string | null;
onBack: () => void;
onSubmitText: (text: string, quickActionKey?: string) => void;
onPrimaryAction: () => void;
onQuickAction?: (action: CreationAgentQuickAction) => void;
onReferenceImageChange?: (file: File) => Promise<void> | void;
onClearReferenceImage?: () => void;
};
const AUTO_SCROLL_FOLLOW_THRESHOLD_PX = 96;
const DOCUMENT_INPUT_ACCEPT =
'.txt,.md,.markdown,.csv,.json,text/plain,text/markdown,text/csv,application/json';
const REFERENCE_IMAGE_INPUT_ACCEPT = 'image/png,image/jpeg,image/webp';
function uniqueRecommendedReplies(recommendedReplies: string[] = []) {
return [
@@ -290,19 +296,26 @@ export function CreationAgentWorkspace({
isBusy = false,
error = null,
quickActions = [],
referenceImagePreviewSrc = null,
referenceImageLabel = null,
referenceImageError = null,
onBack,
onSubmitText,
onPrimaryAction,
onQuickAction,
onReferenceImageChange,
onClearReferenceImage,
}: CreationAgentWorkspaceProps) {
const [draftText, setDraftText] = useState('');
const [documentInputError, setDocumentInputError] = useState<string | null>(
null,
);
const [isParsingDocumentInput, setIsParsingDocumentInput] = useState(false);
const [isReadingReferenceImage, setIsReadingReferenceImage] = useState(false);
// 统一聊天区只在用户仍停留在底部附近时跟随新内容,避免流式回复持续抢走阅读位置。
const messageListRef = useRef<HTMLDivElement | null>(null);
const documentInputRef = useRef<HTMLInputElement | null>(null);
const referenceImageInputRef = useRef<HTMLInputElement | null>(null);
const shouldAutoScrollRef = useRef(true);
useEffect(() => {
@@ -376,7 +389,7 @@ export function CreationAgentWorkspace({
const submit = () => {
const text = draftText.trim();
if (!text || isBusy || isParsingDocumentInput) {
if (!text || isBusy || isParsingDocumentInput || isReadingReferenceImage) {
return;
}
@@ -399,6 +412,10 @@ export function CreationAgentWorkspace({
documentInputRef.current?.click();
};
const openReferenceImagePicker = () => {
referenceImageInputRef.current?.click();
};
const handleDocumentInputChange = async (
event: ChangeEvent<HTMLInputElement>,
) => {
@@ -426,6 +443,25 @@ export function CreationAgentWorkspace({
}
};
const handleReferenceImageInputChange = async (
event: ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0] ?? null;
event.target.value = '';
if (!file || isBusy || isReadingReferenceImage || !onReferenceImageChange) {
return;
}
setIsReadingReferenceImage(true);
try {
await onReferenceImageChange(file);
} finally {
setIsReadingReferenceImage(false);
}
};
return (
<div className="mx-auto flex h-full min-h-0 w-full max-w-6xl flex-col gap-3 overflow-hidden px-1 sm:px-0">
<div
@@ -545,9 +581,36 @@ export function CreationAgentWorkspace({
)}
</div>
{documentInputError || error ? (
{referenceImagePreviewSrc ? (
<div className="mx-4 mb-3 flex items-center gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-2">
<div className="h-12 w-12 shrink-0 overflow-hidden rounded-[0.9rem] bg-[var(--platform-track-fill)]">
<img
src={referenceImagePreviewSrc}
alt="参考图"
className="h-full w-full object-cover"
/>
</div>
<div className="min-w-0 flex-1 truncate text-sm font-semibold text-[var(--platform-text-strong)]">
{referenceImageLabel || '已选择参考图'}
</div>
{onClearReferenceImage ? (
<button
type="button"
disabled={isBusy || isReadingReferenceImage}
onClick={onClearReferenceImage}
className="platform-icon-button h-9 w-9"
aria-label="移除参考图"
title="移除参考图"
>
<X className="h-4 w-4" />
</button>
) : null}
</div>
) : null}
{documentInputError || referenceImageError || error ? (
<div className="mx-4 mb-3 rounded-[1rem] border border-red-200/70 bg-red-50 px-3 py-2 text-sm text-red-600">
{documentInputError || error}
{documentInputError || referenceImageError || error}
</div>
) : null}
@@ -560,6 +623,15 @@ export function CreationAgentWorkspace({
className="hidden"
onChange={handleDocumentInputChange}
/>
{onReferenceImageChange ? (
<input
ref={referenceImageInputRef}
type="file"
accept={REFERENCE_IMAGE_INPUT_ACCEPT}
className="hidden"
onChange={handleReferenceImageInputChange}
/>
) : null}
<button
type="button"
aria-label={
@@ -575,9 +647,30 @@ export function CreationAgentWorkspace({
className={`h-4 w-4 ${isParsingDocumentInput ? 'animate-pulse' : ''}`}
/>
</button>
{onReferenceImageChange ? (
<button
type="button"
aria-label={
isReadingReferenceImage ? '正在读取参考图' : '上传参考图'
}
title={
isReadingReferenceImage ? '正在读取参考图' : '上传参考图'
}
aria-busy={isReadingReferenceImage}
disabled={isBusy || isReadingReferenceImage}
onClick={openReferenceImagePicker}
className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-[var(--platform-text-base)] hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-40"
>
<ImagePlus
className={`h-4 w-4 ${isReadingReferenceImage ? 'animate-pulse' : ''}`}
/>
</button>
) : null}
<textarea
value={draftText}
disabled={isBusy || isParsingDocumentInput}
disabled={
isBusy || isParsingDocumentInput || isReadingReferenceImage
}
rows={2}
onChange={(event) => {
setDraftText(event.target.value);
@@ -595,7 +688,12 @@ export function CreationAgentWorkspace({
<button
type="button"
aria-label="发送"
disabled={isBusy || isParsingDocumentInput || !draftText.trim()}
disabled={
isBusy ||
isParsingDocumentInput ||
isReadingReferenceImage ||
!draftText.trim()
}
onClick={submit}
className={`inline-flex h-11 w-11 items-center justify-center rounded-full text-white disabled:cursor-not-allowed disabled:opacity-40 ${theme.userBubbleClass}`}
>

View File

@@ -1,6 +1,6 @@
/* @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 { afterEach, expect, test, vi } from 'vitest';
@@ -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,22 @@ 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();
const rpgButton = screen.getByRole('button', { name: //u });
const puzzleButton = screen.getByRole('button', { name: /.*/u });
const match3dButton = screen.getByRole('button', { name: //u });
expect(
puzzleButton.compareDocumentPosition(rpgButton) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect((rpgButton as HTMLButtonElement).disabled).toBe(true);
expect(within(rpgButton).getAllByText('敬请期待').length).toBeGreaterThan(0);
expect((match3dButton as HTMLButtonElement).disabled).toBe(false);
expect(
within(match3dButton).getAllByText('经典消除玩法').length,
).toBeGreaterThan(0);
expect(puzzleButton).toBeTruthy();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
rerender(
@@ -83,8 +145,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,6 +167,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,
remixCount: 2,
likeCount: 3,
publishReady: true,
},
]}
@@ -120,11 +184,70 @@ 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();
});
test('creation hub shows puzzle point incentive and claims without opening card', async () => {
const user = userEvent.setup();
const onClaimPuzzlePointIncentive = vi.fn();
const onOpenPuzzleDetail = vi.fn();
render(
<CustomWorldCreationHub
items={[]}
puzzleItems={[
{
workId: 'puzzle:work-incentive',
profileId: 'puzzle-profile-incentive',
ownerUserId: 'user-1',
authorDisplayName: '拼图作者',
levelName: '百梦灯塔',
summary: '拼图作品会展示积分激励。',
themeTags: ['灯塔', '百梦'],
coverImageSrc: null,
publicationStatus: 'published',
updatedAt: new Date('2026-05-01T12:00:00.000Z').toISOString(),
publishedAt: new Date('2026-05-01T12:10:00.000Z').toISOString(),
playCount: 8,
remixCount: 2,
likeCount: 3,
pointIncentiveTotalHalfPoints: 5,
pointIncentiveClaimedPoints: 1,
pointIncentiveTotalPoints: 2.5,
pointIncentiveClaimablePoints: 1,
publishReady: true,
},
]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
onOpenPuzzleDetail={onOpenPuzzleDetail}
onClaimPuzzlePointIncentive={onClaimPuzzlePointIncentive}
/>,
);
expect(screen.getByLabelText('积分激励总数 2.5 光点')).toBeTruthy();
expect(screen.getByLabelText('待领取积分 1 光点')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '领取积分' }));
expect(onClaimPuzzlePointIncentive).toHaveBeenCalledWith(
expect.objectContaining({ profileId: 'puzzle-profile-incentive' }),
);
expect(onOpenPuzzleDetail).not.toHaveBeenCalled();
});
test('creation hub shows RPG public work code from published library entry', () => {
render(
<CustomWorldCreationHub
@@ -158,6 +281,9 @@ test('creation hub shows RPG public work code from published library entry', ()
themeMode: 'tide',
playableNpcCount: 3,
landmarkCount: 4,
playCount: 12,
remixCount: 4,
likeCount: 5,
},
]}
loading={false}
@@ -170,7 +296,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', () => {
@@ -223,7 +353,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();
@@ -249,6 +379,8 @@ 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,
},
]}
@@ -262,11 +394,19 @@ test('creation hub work code copy button copies without opening the card', async
/>,
);
await user.click(
screen.getByRole('button', { name: '复制作品号 PZ-PROFILE1' }),
);
await user.click(screen.getByRole('button', { name: '分享' }));
expect(writeText).toHaveBeenCalledWith('PZ-PROFILE1');
expect(writeText).toHaveBeenCalledWith(
expect.stringContaining('邀请你来玩《沉钟拼图》'),
);
expect(writeText).toHaveBeenCalledWith(
expect.stringContaining('作品号PZ-PROFILE1'),
);
expect(writeText).toHaveBeenCalledWith(
expect.stringContaining('/gallery/puzzle/detail?work=PZ-PROFILE1'),
);
expect(onOpenPuzzleDetail).not.toHaveBeenCalled();
expect(await screen.findByText('已复制')).toBeTruthy();
expect(
await screen.findByRole('button', { name: '分享内容已复制' }),
).toBeTruthy();
});

View File

@@ -42,8 +42,10 @@ 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('大鱼吃小鱼');
});
@@ -65,6 +67,8 @@ test('creation hub renders puzzle works in the same unified list with puzzle tag
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,
},
]}
@@ -80,7 +84,49 @@ 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).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={[]}
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,
likeCount: 0,
publishReady: true,
},
]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
onOpenPuzzleDetail={() => {}}
/>,
);
expect(html).toContain('grid-cols-2');
expect(html).toContain('col-span-2 sm:col-span-1');
expect(html).not.toContain('grid-cols-1 gap-3 md:grid-cols-2');
});

View File

@@ -1,21 +1,33 @@
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 { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
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,16 +41,18 @@ 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;
match3dItems?: Match3DWorkSummary[];
onOpenMatch3DDetail?: (item: Match3DWorkSummary) => void;
onDeleteMatch3D?: ((item: Match3DWorkSummary) => void) | null;
puzzleItems?: PuzzleWorkSummary[];
onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void;
onExperiencePuzzle?: ((profileId: string) => void) | null;
onDeletePuzzle?: ((item: PuzzleWorkSummary) => void) | null;
onClaimPuzzlePointIncentive?: ((item: PuzzleWorkSummary) => void) | null;
claimingPuzzleProfileId?: string | null;
};
function EmptyState({ title }: { title: string }) {
@@ -51,6 +65,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,16 +130,18 @@ export function CustomWorldCreationHub({
onEnterPublished,
onDeletePublished = null,
deletingWorkId = null,
onExperienceRpg = null,
rpgLibraryEntries = [],
bigFishItems = [],
onOpenBigFishDetail,
onExperienceBigFish = null,
onDeleteBigFish = null,
match3dItems = [],
onOpenMatch3DDetail,
onDeleteMatch3D = null,
puzzleItems = [],
onOpenPuzzleDetail,
onExperiencePuzzle = null,
onDeletePuzzle = null,
onClaimPuzzlePointIncentive = null,
claimingPuzzleProfileId = null,
}: CustomWorldCreationHubProps) {
const [activeFilter, setActiveFilter] =
useState<CustomWorldWorkFilter>('all');
@@ -82,21 +151,31 @@ export function CustomWorldCreationHub({
rpgItems: items,
rpgLibraryEntries,
bigFishItems,
match3dItems,
puzzleItems,
canDeleteRpg: Boolean(onDeletePublished),
canDeleteBigFish: Boolean(onDeleteBigFish),
canDeleteMatch3D: Boolean(onDeleteMatch3D),
canDeletePuzzle: Boolean(onDeletePuzzle),
}),
[
bigFishItems,
items,
match3dItems,
onDeleteBigFish,
onDeleteMatch3D,
onDeletePublished,
onDeletePuzzle,
puzzleItems,
rpgLibraryEntries,
],
);
const [metricSnapshot] = useState<WorkMetricSnapshot>(() =>
readWorkMetricSnapshot(),
);
useEffect(() => {
writeWorkMetricSnapshot(shelfItems);
}, [shelfItems]);
const draftCount = shelfItems.filter(
(entry) => entry.status === 'draft',
).length;
@@ -119,6 +198,9 @@ export function CustomWorldCreationHub({
case 'big-fish':
onOpenBigFishDetail?.(item.source.item);
return;
case 'match3d':
onOpenMatch3DDetail?.(item.source.item);
return;
case 'rpg':
if (item.status === 'draft') {
onOpenDraft(item.source.item);
@@ -131,33 +213,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;
@@ -176,6 +231,12 @@ export function CustomWorldCreationHub({
onDeleteBigFish?.(sourceItem);
};
}
case 'match3d': {
const sourceItem = item.source.item;
return () => {
onDeleteMatch3D?.(sourceItem);
};
}
case 'rpg': {
const sourceItem = item.source.item;
return () => {
@@ -185,6 +246,17 @@ export function CustomWorldCreationHub({
}
}
function buildPointIncentiveAction(item: CreationWorkShelfItem) {
if (item.source.kind !== 'puzzle' || !onClaimPuzzlePointIncentive) {
return null;
}
const sourceItem = item.source.item;
return () => {
onClaimPuzzlePointIncentive(sourceItem);
};
}
return (
<div className="platform-page-stage platform-remap-surface space-y-4 px-3 pb-4 pt-3 sm:px-4 sm:pt-4 xl:px-5 xl:pb-5 xl:pt-5">
<div className="space-y-4 xl:space-y-3">
@@ -215,33 +287,40 @@ 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}
onClaimPointIncentive={buildPointIncentiveAction(item)}
pointIncentiveBusy={
item.source.kind === 'puzzle' &&
claimingPuzzleProfileId === item.source.item.profileId
}
/>
))}
</div>

View File

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

View File

@@ -1,64 +1,248 @@
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,
formatCreationPointIncentiveTotal,
} from './creationWorkShelf';
type CustomWorldWorkCardProps = {
item: CreationWorkShelfItem;
previousMetricValues?: Partial<Record<CreationWorkShelfMetricId, number>>;
onOpen: () => void;
onExperience?: (() => void) | null;
onDelete?: (() => void) | null;
deleteBusy?: boolean;
onClaimPointIncentive?: (() => void) | null;
pointIncentiveBusy?: 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,
onClaimPointIncentive = null,
pointIncentiveBusy = false,
}: CustomWorldWorkCardProps) {
const [shareState, setShareState] = useState<'idle' | 'copied' | 'failed'>(
'idle',
);
const shareResetTimerRef = useRef<number | null>(null);
const isPublished = item.status === 'published';
const canClaimPointIncentive =
Boolean(onClaimPointIncentive) &&
(item.pointIncentive?.claimablePoints ?? 0) > 0;
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 +255,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 +263,174 @@ 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="复制作品号"
>
<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]`}
{isPublished ? (
<div className="mt-auto space-y-2 pt-3 sm:pt-4 xl:pt-3">
{item.pointIncentive ? (
<div className="creation-work-card-incentive">
<div
aria-label={`积分激励总数 ${formatCreationPointIncentiveTotal(item.pointIncentive.totalPoints)} 光点`}
className="creation-work-card-incentive__metric"
>
{metric.label}
</span>
<span className="creation-work-card-incentive__label">
</span>
<span className="creation-work-card-incentive__value">
{formatCreationPointIncentiveTotal(
item.pointIncentive.totalPoints,
)}
</span>
</div>
<div
aria-label={`待领取积分 ${item.pointIncentive.claimablePoints} 光点`}
className="creation-work-card-incentive__metric"
>
<span className="creation-work-card-incentive__label">
</span>
<span className="creation-work-card-incentive__value">
{formatCreationMetricCount(
item.pointIncentive.claimablePoints,
)}
</span>
</div>
<button
type="button"
disabled={!canClaimPointIncentive || pointIncentiveBusy}
onClick={(event) => {
event.stopPropagation();
onClaimPointIncentive?.();
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
className="pointer-events-auto creation-work-card-incentive__button"
>
{pointIncentiveBusy ? '领取中' : '领取积分'}
</button>
</div>
) : null}
<div className="grid grid-cols-3 gap-1.5 sm:gap-2">
{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="creation-work-card-stat__label">
{metric.label}
</span>
<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>
<div className="flex flex-wrap gap-2 sm:justify-end xl:gap-1.5">
{onExperience ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onExperience();
}}
className="platform-button platform-button--secondary pointer-events-auto relative z-30 min-h-0 shrink-0 rounded-full px-4 py-2 text-sm xl:px-3.5 xl:py-1.5 xl:text-xs"
>
</button>
) : null}
</div>
</div>
) : null}
</div>
</div>
);

View File

@@ -1,11 +1,17 @@
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
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,
buildMatch3DPublicWorkCode,
buildPuzzlePublicWorkCode,
} from '../../services/publicWorkCode';
import type { CustomWorldProfile } from '../../types';
export type CreationWorkShelfKind = 'rpg' | 'big-fish' | 'puzzle';
export type CreationWorkShelfKind = 'rpg' | 'big-fish' | 'match3d' | 'puzzle';
export type CreationWorkShelfStatus = 'draft' | 'published';
export type CreationWorkShelfBadgeTone = 'warm' | 'success' | 'neutral';
@@ -16,10 +22,25 @@ 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 CreationWorkShelfPointIncentive = {
totalHalfPoints: number;
totalPoints: number;
claimablePoints: number;
};
export type CreationWorkShelfSource =
@@ -31,6 +52,10 @@ export type CreationWorkShelfSource =
kind: 'big-fish';
item: BigFishWorkSummary;
}
| {
kind: 'match3d';
item: Match3DWorkSummary;
}
| {
kind: 'puzzle';
item: PuzzleWorkSummary;
@@ -41,19 +66,19 @@ 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[];
pointIncentive?: CreationWorkShelfPointIncentive;
source: CreationWorkShelfSource;
};
@@ -61,18 +86,22 @@ export function buildCreationWorkShelfItems(params: {
rpgItems: CustomWorldWorkSummary[];
rpgLibraryEntries?: CustomWorldLibraryEntry<CustomWorldProfile>[];
bigFishItems: BigFishWorkSummary[];
match3dItems?: Match3DWorkSummary[];
puzzleItems: PuzzleWorkSummary[];
canDeleteRpg?: boolean;
canDeleteBigFish?: boolean;
canDeleteMatch3D?: boolean;
canDeletePuzzle?: boolean;
}) {
const {
rpgItems,
rpgLibraryEntries = [],
bigFishItems,
match3dItems = [],
puzzleItems,
canDeleteRpg = false,
canDeleteBigFish = false,
canDeleteMatch3D = false,
canDeletePuzzle = false,
} = params;
@@ -83,6 +112,9 @@ export function buildCreationWorkShelfItems(params: {
...bigFishItems.map((item) =>
mapBigFishWorkToShelfItem(item, canDeleteBigFish),
),
...match3dItems.map((item) =>
mapMatch3DWorkToShelfItem(item, canDeleteMatch3D),
),
...puzzleItems.map((item) =>
mapPuzzleWorkToShelfItem(item, canDeletePuzzle),
),
@@ -101,67 +133,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,92 +178,209 @@ 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 },
};
}
function mapMatch3DWorkToShelfItem(
item: Match3DWorkSummary,
canDelete: boolean,
): CreationWorkShelfItem {
const status = item.publicationStatus === 'published' ? 'published' : 'draft';
const publicWorkCode =
status === 'published' ? buildMatch3DPublicWorkCode(item.profileId) : null;
return {
id: item.workId,
kind: 'match3d',
status,
title: item.gameName,
summary: item.summary,
updatedAt: item.updatedAt,
coverImageSrc: item.coverImageSrc ?? null,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
publicWorkCode,
sharePath:
publicWorkCode && status === 'published'
? buildPublicWorkStagePath('work-detail', publicWorkCode)
: null,
openActionLabel: status === 'published' ? '查看详情' : '继续创作',
canDelete,
canShare: status === 'published' && Boolean(publicWorkCode),
badges: [
buildStatusBadge(status),
{ id: 'type', label: '抓鹅', tone: 'neutral' },
],
metrics:
status === 'published'
? buildPublishedMetrics({
playCount: item.playCount,
remixCount: 0,
likeCount: 0,
})
: [],
source: { kind: 'match3d', item },
};
}
function mapPuzzleWorkToShelfItem(
item: PuzzleWorkSummary,
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,
title: item.workTitle?.trim() || item.levelName.trim() || '未命名拼图',
summary:
item.workDescription?.trim() ||
item.summary.trim() ||
(status === 'draft' ? '未填写作品描述' : ''),
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,
})
: [],
pointIncentive:
status === 'published'
? {
totalHalfPoints: normalizeMetricCount(
item.pointIncentiveTotalHalfPoints,
),
totalPoints: normalizePointIncentiveTotal(
item.pointIncentiveTotalPoints,
item.pointIncentiveTotalHalfPoints,
),
claimablePoints: normalizeMetricCount(
item.pointIncentiveClaimablePoints,
),
}
: undefined,
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}`;
}
export function formatCreationPointIncentiveTotal(value?: number | null) {
const normalized = Math.max(0, value ?? 0);
return Number.isInteger(normalized)
? normalized.toFixed(0)
: normalized.toFixed(1);
}
function normalizePointIncentiveTotal(
totalPoints?: number | null,
totalHalfPoints?: number | null,
) {
if (Number.isFinite(totalPoints)) {
return Math.max(0, totalPoints ?? 0);
}
return normalizeMetricCount(totalHalfPoints) / 2;
}
function buildStatusBadge(
status: CreationWorkShelfStatus,
): CreationWorkShelfBadge {

View File

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

View File

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

View File

@@ -0,0 +1,215 @@
import { useState } from 'react';
import type {
ExecuteMatch3DActionRequest,
Match3DAgentSessionSnapshot,
Match3DAnchorItemResponse,
SendMatch3DMessageRequest,
} from '../../../packages/shared/src/contracts/match3dAgent';
import {
buildCreationAgentChatMessage,
createCreationAgentChatQuickActions,
createCreationAgentClientMessageId,
resolveCreationAgentQuickActionMessage,
} from '../../services/creation-agent';
import {
type CreationAgentAnchorView,
type CreationAgentSessionView,
type CreationAgentTheme,
CreationAgentWorkspace,
} from '../creation-agent';
type Match3DAgentWorkspaceProps = {
session: Match3DAgentSessionSnapshot | null;
streamingReplyText?: string;
isStreamingReply?: boolean;
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onSubmitMessage: (payload: SendMatch3DMessageRequest) => void;
onExecuteAction: (payload: ExecuteMatch3DActionRequest) => void;
};
type Match3DReferenceImageState = {
src: string;
label: string;
};
const MATCH3D_AGENT_THEME: CreationAgentTheme = {
accentTextClass: 'text-lime-100/86',
accentBgClass: 'bg-lime-200',
accentButtonClass: 'bg-lime-200 shadow-emerald-950/20',
userBubbleClass: 'bg-emerald-600 text-white',
heroClass:
'border border-lime-100/18 bg-[radial-gradient(circle_at_top_left,rgba(190,242,100,0.24),transparent_34%),radial-gradient(circle_at_bottom_right,rgba(251,146,60,0.2),transparent_32%),linear-gradient(135deg,rgba(20,83,45,0.96),rgba(39,39,42,0.96))]',
anchorGridClass: 'grid gap-2 sm:grid-cols-3',
};
const MATCH3D_QUICK_ACTIONS = [
...createCreationAgentChatQuickActions(),
{
key: 'match3d-auto-config',
label: '自动配置',
},
];
function readMatch3DReferenceImageAsDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
if (!file.type.startsWith('image/')) {
reject(new Error('请选择图片文件。'));
return;
}
const reader = new FileReader();
reader.onerror = () => reject(new Error('参考图读取失败,请重试。'));
reader.onload = () => resolve(String(reader.result || ''));
reader.readAsDataURL(file);
});
}
function mapMatch3DAnchor(
anchor: Match3DAnchorItemResponse,
): CreationAgentAnchorView {
return {
key: anchor.key,
label: anchor.label,
value: anchor.value,
status: anchor.status,
};
}
function mapMatch3DSession(
session: Match3DAgentSessionSnapshot,
): CreationAgentSessionView {
// 中文注释:抓大鹅 F1 只展示聊天与配置锚点,草稿结果交给后续结果页承接。
const chatMessages = session.messages.filter(
(message) =>
message.kind === 'chat' ||
message.kind === 'summary' ||
message.kind === 'warning',
);
return {
sessionId: session.sessionId,
title: null,
assistantSummary: null,
currentTurn: session.currentTurn,
progressPercent: session.progressPercent,
anchors: [
session.anchorPack.theme,
session.anchorPack.clearCount,
session.anchorPack.difficulty,
].map(mapMatch3DAnchor),
messages: chatMessages,
recommendedReplies: [],
};
}
function buildMatch3DChatPayload({
text,
quickFillRequested = false,
referenceImageSrc,
}: {
text: string;
quickFillRequested?: boolean;
referenceImageSrc?: string | null;
}) {
return buildCreationAgentChatMessage<{
referenceImageSrc?: string | null;
}>({
clientMessageId: createCreationAgentClientMessageId('match3d'),
text,
quickFillRequested,
extraPayload: {
referenceImageSrc: referenceImageSrc || null,
},
});
}
export function Match3DAgentWorkspace({
session,
streamingReplyText = '',
isStreamingReply = false,
isBusy = false,
error = null,
onBack,
onSubmitMessage,
onExecuteAction,
}: Match3DAgentWorkspaceProps) {
const [referenceImage, setReferenceImage] =
useState<Match3DReferenceImageState | null>(null);
const [referenceImageError, setReferenceImageError] = useState<string | null>(
null,
);
return (
<CreationAgentWorkspace
session={session ? mapMatch3DSession(session) : null}
theme={MATCH3D_AGENT_THEME}
loadingText="正在准备抓大鹅共创工作区..."
composerPlaceholder="题材、消除次数、难度..."
primaryActionLabel="生成结果页"
streamingReplyText={streamingReplyText}
isStreamingReply={isStreamingReply}
isBusy={isBusy}
error={error}
quickActions={MATCH3D_QUICK_ACTIONS}
referenceImagePreviewSrc={referenceImage?.src ?? null}
referenceImageLabel={referenceImage?.label ?? null}
referenceImageError={referenceImageError}
onBack={onBack}
onSubmitText={(text) => {
onSubmitMessage(
buildMatch3DChatPayload({
text,
referenceImageSrc: referenceImage?.src ?? null,
}),
);
}}
onPrimaryAction={() => {
onExecuteAction({ action: 'match3d_compile_draft' });
}}
onQuickAction={(action) => {
const quickActionMessage =
action.key === 'match3d-auto-config'
? {
text: '自动配置',
quickFillRequested: true,
}
: resolveCreationAgentQuickActionMessage(
action.key,
'请总结一下当前抓大鹅设定。',
);
onSubmitMessage(
buildMatch3DChatPayload({
...quickActionMessage,
referenceImageSrc: referenceImage?.src ?? null,
}),
);
}}
onReferenceImageChange={async (file) => {
try {
const dataUrl = await readMatch3DReferenceImageAsDataUrl(file);
setReferenceImage({
src: dataUrl,
label: file.name.trim() || '本地参考图',
});
setReferenceImageError(null);
} catch (caughtError) {
setReferenceImageError(
caughtError instanceof Error
? caughtError.message
: '参考图读取失败,请重试。',
);
}
}}
onClearReferenceImage={() => {
setReferenceImage(null);
setReferenceImageError(null);
}}
/>
);
}
export default Match3DAgentWorkspace;

View File

@@ -0,0 +1,105 @@
import { ArrowLeft, Edit3, Sparkles } from 'lucide-react';
import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent';
type Match3DDraftReadyViewProps = {
session: Match3DAgentSessionSnapshot;
isBusy?: boolean;
error?: string | null;
onBack: () => void;
};
export function Match3DDraftReadyView({
session,
isBusy = false,
error = null,
onBack,
}: Match3DDraftReadyViewProps) {
const draft = session.draft;
const title = draft?.gameName || '抓大鹅草稿';
return (
<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>
<section className="platform-subpanel min-h-0 flex-1 overflow-y-auto rounded-[1.5rem] p-4 sm:p-5">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start">
<div className="grid aspect-square w-full max-w-[10rem] shrink-0 place-items-center rounded-[1.2rem] bg-[radial-gradient(circle_at_30%_25%,rgba(190,242,100,0.36),transparent_34%),linear-gradient(135deg,rgba(16,185,129,0.18),rgba(251,146,60,0.16))] text-emerald-800">
<Sparkles className="h-10 w-10" />
</div>
<div className="min-w-0 flex-1">
<div className="text-2xl font-black leading-tight text-[var(--platform-text-strong)] sm:text-3xl">
{title}
</div>
<div className="mt-2 text-sm leading-6 text-[var(--platform-text-base)]">
{draft?.summaryText ?? session.lastAssistantReply ?? ''}
</div>
{draft ? (
<div className="mt-5 grid gap-2 sm:grid-cols-3">
<div className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/68 px-3 py-3">
<div className="text-[11px] font-bold tracking-[0.16em] text-[var(--platform-text-soft)]">
</div>
<div className="mt-1 truncate text-sm font-semibold text-[var(--platform-text-strong)]">
{draft.themeText}
</div>
</div>
<div className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/68 px-3 py-3">
<div className="text-[11px] font-bold tracking-[0.16em] text-[var(--platform-text-soft)]">
</div>
<div className="mt-1 text-sm font-semibold text-[var(--platform-text-strong)]">
{draft.totalItemCount ?? draft.clearCount * 3}
</div>
</div>
<div className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/68 px-3 py-3">
<div className="text-[11px] font-bold tracking-[0.16em] text-[var(--platform-text-soft)]">
</div>
<div className="mt-1 text-sm font-semibold text-[var(--platform-text-strong)]">
{draft.difficulty}
</div>
</div>
</div>
) : null}
{error ? (
<div className="platform-banner platform-banner--danger mt-4 rounded-[1.25rem] text-sm leading-6">
{error}
</div>
) : null}
</div>
</div>
</section>
<div className="mt-4 flex justify-end pb-[max(0.25rem,env(safe-area-inset-bottom))]">
<button
type="button"
disabled
className="platform-button platform-button--primary cursor-not-allowed opacity-55"
>
<span className="inline-flex items-center gap-2">
<Edit3 className="h-4 w-4" />
</span>
</button>
</div>
</div>
);
}
export default Match3DDraftReadyView;

View File

@@ -0,0 +1,103 @@
// @vitest-environment jsdom
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, test, vi } from 'vitest';
import type { Match3DWorkProfile } from '../../../packages/shared/src/contracts/match3dWorks';
import * as match3dWorksService from '../../services/match3d-works';
import { Match3DResultView } from './Match3DResultView';
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: ({
src,
alt,
className,
}: {
src?: string | null;
alt?: string;
className?: string;
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
}));
vi.mock('../../services/match3d-works', () => ({
publishMatch3DWork: vi.fn(),
updateMatch3DWork: vi.fn(),
}));
afterEach(() => {
vi.clearAllMocks();
});
function createProfile(
overrides: Partial<Match3DWorkProfile> = {},
): Match3DWorkProfile {
return {
workId: 'match3d-work-1',
profileId: 'match3d-profile-1',
ownerUserId: 'user-1',
sourceSessionId: 'match3d-session-1',
gameName: '水果抓大鹅',
themeText: '水果',
summary: '水果主题的经典消除玩法。',
tags: ['水果'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 4,
difficulty: 3,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-01T00:00:00.000Z',
publishedAt: null,
publishReady: false,
...overrides,
};
}
describe('Match3DResultView', () => {
test('试玩只要求基础配置可保存,不被发布封面门槛阻断', async () => {
const profile = createProfile();
const onStartTestRun = vi.fn();
vi.mocked(match3dWorksService.updateMatch3DWork).mockResolvedValue({
item: profile,
});
render(
<Match3DResultView
profile={profile}
onBack={() => {}}
onStartTestRun={onStartTestRun}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
await waitFor(() => {
expect(match3dWorksService.updateMatch3DWork).toHaveBeenCalledWith(
'match3d-profile-1',
expect.objectContaining({
clearCount: 4,
difficulty: 3,
gameName: '水果抓大鹅',
}),
);
});
expect(onStartTestRun).toHaveBeenCalledWith(profile);
expect(match3dWorksService.publishMatch3DWork).not.toHaveBeenCalled();
});
test('发布仍要求封面和标签数量满足门槛', () => {
render(
<Match3DResultView
profile={createProfile()}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
const publishButton = screen.getByRole('button', { name: '发布' });
expect(publishButton).toHaveProperty('disabled', true);
fireEvent.click(publishButton);
expect(match3dWorksService.publishMatch3DWork).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,588 @@
import {
ArrowLeft,
CheckCircle2,
ImagePlus,
Loader2,
Play,
Send,
} from 'lucide-react';
import { type ChangeEvent, useEffect, useMemo, useState } from 'react';
import type { Match3DResultDraft } from '../../../packages/shared/src/contracts/match3dAgent';
import type {
Match3DWorkProfile,
PutMatch3DWorkRequest,
} from '../../../packages/shared/src/contracts/match3dWorks';
import {
publishMatch3DWork,
updateMatch3DWork,
} from '../../services/match3d-works';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
type Match3DResultViewProps = {
profile: Match3DWorkProfile;
draft?: Match3DResultDraft | null;
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onSaved?: (profile: Match3DWorkProfile) => void;
onPublished?: (profile: Match3DWorkProfile) => void;
onStartTestRun: (profile: Match3DWorkProfile) => void;
};
type Match3DAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
type Match3DResultEditState = {
gameName: string;
summary: string;
tagsText: string;
coverImageSrc: string;
themeText: string;
clearCountText: string;
difficultyText: string;
};
const MATCH3D_MIN_TAG_COUNT = 3;
const MATCH3D_MAX_TAG_COUNT = 6;
const MATCH3D_AUTOSAVE_DEBOUNCE_MS = 600;
function normalizeTags(value: string) {
return [
...new Set(
value
.split(/[\n,]/u)
.map((entry) => entry.trim())
.filter(Boolean),
),
];
}
function normalizePositiveInteger(value: string) {
const parsed = Number.parseInt(value.trim(), 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
function normalizeDifficulty(value: string) {
const parsed = Number.parseInt(value.trim(), 10);
return Number.isFinite(parsed) && parsed >= 1 && parsed <= 10
? parsed
: null;
}
function createEditState(profile: Match3DWorkProfile): Match3DResultEditState {
return {
gameName: profile.gameName,
summary: profile.summary,
tagsText: profile.tags.join(''),
coverImageSrc:
profile.coverImageSrc?.trim() || profile.referenceImageSrc?.trim() || '',
themeText: profile.themeText,
clearCountText: String(profile.clearCount),
difficultyText: String(profile.difficulty),
};
}
function buildSavePayload(
editState: Match3DResultEditState,
): PutMatch3DWorkRequest | null {
const clearCount = normalizePositiveInteger(editState.clearCountText);
const difficulty = normalizeDifficulty(editState.difficultyText);
const gameName = editState.gameName.trim();
const themeText = editState.themeText.trim();
const summary = editState.summary.trim();
const tags = normalizeTags(editState.tagsText);
if (!gameName || !themeText || !summary || !clearCount || !difficulty) {
return null;
}
return {
gameName,
themeText,
summary,
tags,
coverImageSrc: editState.coverImageSrc.trim() || null,
clearCount,
difficulty,
};
}
function buildPublishBlockers(editState: Match3DResultEditState) {
const tags = normalizeTags(editState.tagsText);
const blockers = [
...(editState.gameName.trim() ? [] : ['游戏名称不能为空。']),
...(editState.themeText.trim() ? [] : ['题材主题不能为空。']),
...(editState.summary.trim() ? [] : ['简介不能为空。']),
...(editState.coverImageSrc.trim() ? [] : ['封面图不能为空。']),
...(tags.length >= MATCH3D_MIN_TAG_COUNT && tags.length <= MATCH3D_MAX_TAG_COUNT
? []
: [`标签数量需要在 ${MATCH3D_MIN_TAG_COUNT}${MATCH3D_MAX_TAG_COUNT} 个之间。`]),
...(normalizePositiveInteger(editState.clearCountText)
? []
: ['需要消除次数必须为正整数。']),
...(normalizeDifficulty(editState.difficultyText)
? []
: ['难度必须为 1 到 10。']),
];
return [...new Set(blockers)];
}
function buildTestRunBlockers(editState: Match3DResultEditState) {
const blockers = [
...(editState.gameName.trim() ? [] : ['游戏名称不能为空。']),
...(editState.themeText.trim() ? [] : ['题材主题不能为空。']),
...(editState.summary.trim() ? [] : ['简介不能为空。']),
...(normalizePositiveInteger(editState.clearCountText)
? []
: ['需要消除次数必须为正整数。']),
...(normalizeDifficulty(editState.difficultyText)
? []
: ['难度必须为 1 到 10。']),
];
return [...new Set(blockers)];
}
function readImageAsDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
if (!file.type.startsWith('image/')) {
reject(new Error('请选择图片文件。'));
return;
}
const reader = new FileReader();
reader.onerror = () => reject(new Error('封面图读取失败,请重试。'));
reader.onload = () => resolve(String(reader.result || ''));
reader.readAsDataURL(file);
});
}
function buildPlayableProfile(
profile: Match3DWorkProfile,
editState: Match3DResultEditState,
) {
const payload = buildSavePayload(editState);
if (!payload) {
return profile;
}
return {
...profile,
gameName: payload.gameName,
themeText: payload.themeText ?? profile.themeText,
summary: payload.summary,
tags: payload.tags,
coverImageSrc: payload.coverImageSrc,
clearCount: payload.clearCount,
difficulty: payload.difficulty,
};
}
function Match3DResultHeader({
autoSaveState,
isBusy,
onBack,
}: {
autoSaveState: Match3DAutoSaveState;
isBusy: boolean;
onBack: () => void;
}) {
const badge =
autoSaveState === 'saving' ? (
<div className="platform-pill platform-pill--warm px-3 py-1 text-[11px]">
</div>
) : autoSaveState === 'saved' ? (
<div className="platform-pill platform-pill--success px-3 py-1 text-[11px]">
</div>
) : autoSaveState === 'error' ? (
<div className="platform-pill platform-pill--rose px-3 py-1 text-[11px]">
</div>
) : null;
return (
<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>
{badge}
</div>
);
}
export function Match3DResultView({
profile,
draft = null,
isBusy = false,
error = null,
onBack,
onSaved,
onPublished,
onStartTestRun,
}: Match3DResultViewProps) {
const [editState, setEditState] = useState(() => createEditState(profile));
const [autoSaveState, setAutoSaveState] =
useState<Match3DAutoSaveState>('idle');
const [localError, setLocalError] = useState<string | null>(null);
const [isPublishing, setIsPublishing] = useState(false);
const [isStartingTestRun, setIsStartingTestRun] = useState(false);
const blockers = useMemo(() => buildPublishBlockers(editState), [editState]);
const testRunBlockers = useMemo(
() => buildTestRunBlockers(editState),
[editState],
);
const canStartTestRun = testRunBlockers.length === 0;
const canSubmit = blockers.length === 0;
const totalItemCount =
(normalizePositiveInteger(editState.clearCountText) ?? profile.clearCount) *
3;
useEffect(() => {
setEditState(createEditState(profile));
setAutoSaveState('idle');
setLocalError(null);
}, [profile.profileId, profile.updatedAt]);
useEffect(() => {
const payload = buildSavePayload(editState);
if (!payload) {
return undefined;
}
const currentTags = normalizeTags(profile.tags.join(''));
const nextTags = payload.tags;
const changed =
payload.gameName !== profile.gameName ||
payload.themeText !== profile.themeText ||
payload.summary !== profile.summary ||
(payload.coverImageSrc ?? '') !== (profile.coverImageSrc ?? '') ||
payload.clearCount !== profile.clearCount ||
payload.difficulty !== profile.difficulty ||
nextTags.length !== currentTags.length ||
nextTags.some((tag, index) => tag !== currentTags[index]);
if (!changed) {
return undefined;
}
setAutoSaveState('saving');
setLocalError(null);
let cancelled = false;
const timer = window.setTimeout(() => {
void updateMatch3DWork(profile.profileId, payload)
.then(({ item }) => {
if (cancelled) {
return;
}
setAutoSaveState('saved');
onSaved?.(item);
})
.catch((saveError) => {
if (cancelled) {
return;
}
setAutoSaveState('error');
setLocalError(
saveError instanceof Error ? saveError.message : '自动保存失败。',
);
});
}, MATCH3D_AUTOSAVE_DEBOUNCE_MS);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [editState, onSaved, profile]);
const saveNow = async () => {
const payload = buildSavePayload(editState);
if (!payload) {
setLocalError(testRunBlockers[0] ?? '请补全作品信息。');
return null;
}
setAutoSaveState('saving');
setLocalError(null);
const { item } = await updateMatch3DWork(profile.profileId, payload);
setAutoSaveState('saved');
onSaved?.(item);
return item;
};
const handleCoverImageChange = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0] ?? null;
event.target.value = '';
if (!file) {
return;
}
try {
const dataUrl = await readImageAsDataUrl(file);
setEditState((current) => ({
...current,
coverImageSrc: dataUrl,
}));
setLocalError(null);
} catch (caughtError) {
setLocalError(
caughtError instanceof Error ? caughtError.message : '封面图读取失败。',
);
}
};
const handleStartTestRun = async () => {
if (!canStartTestRun || isStartingTestRun) {
setLocalError(testRunBlockers[0] ?? null);
return;
}
setIsStartingTestRun(true);
try {
const savedProfile = await saveNow();
onStartTestRun(savedProfile ?? buildPlayableProfile(profile, editState));
} catch (caughtError) {
setLocalError(
caughtError instanceof Error ? caughtError.message : '启动试玩前保存失败。',
);
} finally {
setIsStartingTestRun(false);
}
};
const handlePublish = async () => {
if (!canSubmit || isPublishing) {
setLocalError(blockers[0] ?? null);
return;
}
setIsPublishing(true);
try {
const savedProfile = await saveNow();
const { item } = await publishMatch3DWork(
savedProfile?.profileId ?? profile.profileId,
);
onPublished?.(item);
setLocalError(null);
} catch (caughtError) {
setLocalError(
caughtError instanceof Error ? caughtError.message : '发布抓大鹅作品失败。',
);
} finally {
setIsPublishing(false);
}
};
const busy = isBusy || isPublishing || isStartingTestRun;
const displayError = error ?? localError;
return (
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full flex-col xl:max-w-[min(100%,88rem)]">
<Match3DResultHeader
autoSaveState={autoSaveState}
isBusy={busy}
onBack={onBack}
/>
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
<div className="grid gap-3 lg:grid-cols-[minmax(17rem,0.72fr)_minmax(0,1fr)]">
<section className="platform-subpanel rounded-[1.5rem] p-4 sm:p-5">
<div className="aspect-[4/3] overflow-hidden rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-[radial-gradient(circle_at_35%_24%,rgba(190,242,100,0.28),transparent_34%),linear-gradient(135deg,rgba(16,185,129,0.18),rgba(251,146,60,0.16))]">
{editState.coverImageSrc ? (
<ResolvedAssetImage
src={editState.coverImageSrc}
alt=""
aria-hidden="true"
className="h-full w-full object-cover"
/>
) : (
<div className="grid h-full w-full place-items-center text-emerald-700">
<ImagePlus className="h-10 w-10" />
</div>
)}
</div>
<label className="platform-button platform-button--ghost mt-3 flex min-h-10 cursor-pointer items-center justify-center gap-2 px-3 py-2 text-sm">
<ImagePlus className="h-4 w-4" />
<input
type="file"
accept="image/*"
className="sr-only"
disabled={busy}
onChange={handleCoverImageChange}
/>
</label>
<div className="mt-3 grid grid-cols-3 gap-2 text-center text-xs font-bold text-[var(--platform-text-base)]">
<div className="rounded-[1rem] bg-white/68 px-2 py-2">
{totalItemCount}
</div>
<div className="rounded-[1rem] bg-white/68 px-2 py-2">
{editState.clearCountText || '-'}
</div>
<div className="rounded-[1rem] bg-white/68 px-2 py-2">
{editState.difficultyText || '-'}
</div>
</div>
</section>
<section className="platform-subpanel rounded-[1.5rem] p-4 sm:p-5">
<div className="grid gap-3 sm:grid-cols-2">
<label className="block sm:col-span-2">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<input
value={editState.gameName}
disabled={busy}
onChange={(event) =>
setEditState({ ...editState, gameName: 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"
/>
</label>
<label className="block sm:col-span-2">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<input
value={editState.tagsText}
disabled={busy}
onChange={(event) =>
setEditState({ ...editState, tagsText: event.target.value })
}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
/>
</label>
<label className="block sm:col-span-2">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<textarea
value={editState.summary}
disabled={busy}
onChange={(event) =>
setEditState({ ...editState, summary: event.target.value })
}
rows={3}
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
/>
</label>
<label className="block sm:col-span-2">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<input
value={editState.themeText}
disabled={busy}
onChange={(event) =>
setEditState({ ...editState, themeText: event.target.value })
}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
/>
</label>
<label className="block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<input
value={editState.clearCountText}
inputMode="numeric"
disabled={busy}
onChange={(event) =>
setEditState({
...editState,
clearCountText: event.target.value,
})
}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
/>
</label>
<label className="block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<input
value={editState.difficultyText}
inputMode="numeric"
disabled={busy}
onChange={(event) =>
setEditState({
...editState,
difficultyText: event.target.value,
})
}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
/>
</label>
</div>
{draft?.referenceImageSrc || profile.referenceImageSrc ? (
<div className="mt-4 overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/68 p-2">
<ResolvedAssetImage
src={draft?.referenceImageSrc ?? profile.referenceImageSrc ?? ''}
alt=""
aria-hidden="true"
className="h-24 w-full rounded-[0.8rem] object-cover"
/>
</div>
) : null}
</section>
</div>
</div>
{displayError ? (
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
{displayError}
</div>
) : null}
<div className="mt-3 flex flex-col gap-2 pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:flex-row sm:justify-end">
<button
type="button"
onClick={handleStartTestRun}
disabled={!canStartTestRun || busy}
className={`platform-button platform-button--ghost min-h-11 justify-center gap-2 px-5 py-3 ${!canStartTestRun || busy ? 'cursor-not-allowed opacity-55' : ''}`}
>
{isStartingTestRun ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Play className="h-4 w-4" />
)}
</button>
<button
type="button"
onClick={handlePublish}
disabled={!canSubmit || busy}
className={`platform-button platform-button--primary min-h-11 justify-center gap-2 px-5 py-3 ${!canSubmit || busy ? 'cursor-not-allowed opacity-55' : ''}`}
>
{isPublishing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : profile.publicationStatus === 'published' ? (
<CheckCircle2 className="h-4 w-4" />
) : (
<Send className="h-4 w-4" />
)}
{profile.publicationStatus === 'published' ? '更新发布' : '发布'}
</button>
</div>
</div>
);
}
export default Match3DResultView;

View File

@@ -0,0 +1 @@
export { Match3DResultView } from './Match3DResultView';

View File

@@ -0,0 +1,179 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import type {
Match3DClickItemRequest,
Match3DRunSnapshot,
} from '../../../packages/shared/src/contracts/match3dRuntime';
import {
confirmLocalMatch3DClick,
startLocalMatch3DRun,
} from '../../services/match3d-runtime';
import { Match3DRuntimeShell } from './Match3DRuntimeShell';
function renderRuntime(run: Match3DRunSnapshot) {
let currentRun = run;
let authorityRun = run;
const onClickItem = vi.fn(async (payload: Match3DClickItemRequest) => {
const result = await confirmLocalMatch3DClick(authorityRun, payload);
authorityRun = result.run;
return result;
});
const onOptimisticRunChange = vi.fn((nextRun: Match3DRunSnapshot) => {
currentRun = nextRun;
rerender(
<Match3DRuntimeShell
run={currentRun}
onBack={vi.fn()}
onRestart={vi.fn()}
onOptimisticRunChange={onOptimisticRunChange}
onClickItem={onClickItem}
/>,
);
});
const { rerender } = render(
<Match3DRuntimeShell
run={currentRun}
onBack={vi.fn()}
onRestart={vi.fn()}
onOptimisticRunChange={onOptimisticRunChange}
onClickItem={onClickItem}
/>,
);
return {
onClickItem,
onOptimisticRunChange,
};
}
test('展示圆形空间和 7 格备选栏', () => {
renderRuntime(startLocalMatch3DRun(4));
expect(screen.getByTestId('match3d-board')).toBeTruthy();
expect(screen.getAllByTestId('match3d-tray-slot')).toHaveLength(7);
});
test('点击可见物品后先乐观入槽再等待确认', async () => {
const run = startLocalMatch3DRun(4);
const clickableItem = run.items.find((item) => item.clickable);
expect(clickableItem).toBeTruthy();
const { onClickItem, onOptimisticRunChange } = renderRuntime(run);
fireEvent.click(
screen.getByTestId(`match3d-item-${clickableItem!.itemInstanceId}`),
);
expect(onOptimisticRunChange).toHaveBeenCalled();
await waitFor(() => expect(onClickItem).toHaveBeenCalledTimes(1));
});
test('后端形状视觉键不会被统一兜底成红色苹字', () => {
const run = startLocalMatch3DRun(2);
run.items = run.items.slice(0, 2).map((item, index) => ({
...item,
itemInstanceId: `shape-${index}`,
itemTypeId: `shape-type-${index}`,
visualKey: index === 0 ? 'red_circle' : 'yellow_triangle',
x: 0.42 + index * 0.16,
y: 0.5,
layer: index,
clickable: true,
}));
renderRuntime(run);
expect(screen.getByTestId('match3d-visual-red_circle')).toBeTruthy();
expect(screen.getByTestId('match3d-visual-yellow_triangle')).toBeTruthy();
expect(screen.queryAllByText('苹')).toHaveLength(0);
});
test('水果题材视觉键也渲染为无文字纯色几何体', () => {
const run = startLocalMatch3DRun(3);
run.items = run.items.slice(0, 3).map((item, index) => ({
...item,
itemInstanceId: `fruit-${index}`,
itemTypeId: `fruit-type-${index}`,
visualKey:
index === 0
? 'watermelon-green'
: index === 1
? 'apple-red'
: 'grape-purple',
x: 0.35 + index * 0.15,
y: 0.5,
radius: index === 0 ? 0.12 : index === 1 ? 0.09 : 0.07,
layer: index,
clickable: true,
}));
renderRuntime(run);
expect(screen.getByTestId('match3d-visual-watermelon-green')).toBeTruthy();
expect(
screen.getByTestId('match3d-visual-apple-red').getAttribute('data-shape'),
).toBe('heart');
expect(
screen
.getByTestId('match3d-visual-grape-purple')
.getAttribute('data-shape'),
).toBe('star');
expect(screen.queryByText('苹果')).toBeNull();
expect(screen.queryByText('苹')).toBeNull();
});
test('运行态支持梯形和平行四边形等差异化几何造型', () => {
const run = startLocalMatch3DRun(3);
run.items = run.items.slice(0, 3).map((item, index) => ({
...item,
itemInstanceId: `geometry-${index}`,
itemTypeId: `geometry-type-${index}`,
visualKey:
index === 0
? 'peach-pink'
: index === 1
? 'banana-yellow'
: 'orange_hexagon',
x: 0.35 + index * 0.15,
y: 0.5,
layer: index,
clickable: true,
}));
renderRuntime(run);
expect(
screen.getByTestId('match3d-visual-peach-pink').getAttribute('data-shape'),
).toBe('trapezoid');
expect(
screen
.getByTestId('match3d-visual-banana-yellow')
.getAttribute('data-shape'),
).toBe('parallelogram');
expect(
screen
.getByTestId('match3d-visual-orange_hexagon')
.getAttribute('data-shape'),
).toBe('hexagon');
});
test('异常旧坐标只做显示层收束,不让物品贴出圆形空间', () => {
const run = startLocalMatch3DRun(1);
const item = run.items[0]!;
run.items = [
{
...item,
itemInstanceId: 'legacy-outside',
visualKey: 'apple-red',
x: -0.4,
y: 0.5,
radius: 0.1,
clickable: true,
},
];
renderRuntime(run);
const token = screen.getByTestId(
'match3d-item-legacy-outside',
) as HTMLElement;
expect(parseFloat(token.style.left)).toBeGreaterThanOrEqual(0);
expect(parseFloat(token.style.left)).toBeLessThanOrEqual(100);
});

View File

@@ -0,0 +1,767 @@
import {
ArrowLeft,
CheckCircle2,
Clock3,
RotateCcw,
Sparkles,
XCircle,
} from 'lucide-react';
import { type PointerEvent, useEffect, useMemo, useRef, useState } from 'react';
import type {
Match3DClickItemRequest,
Match3DClickItemResult,
Match3DItemSnapshot,
Match3DRunSnapshot,
Match3DTraySlot,
} from '../../../packages/shared/src/contracts/match3dRuntime';
import { MATCH3D_VISUAL_SEEDS } from '../../services/match3d-runtime';
type Match3DRuntimeShellProps = {
run: Match3DRunSnapshot | null;
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onRestart: () => void;
onOptimisticRunChange: (run: Match3DRunSnapshot) => void;
onClickItem: (
payload: Match3DClickItemRequest,
) => Promise<Match3DClickItemResult>;
onTimeExpired?: () => void;
};
type PendingClick = {
clientEventId: string;
itemInstanceId: string;
previousRun: Match3DRunSnapshot;
};
type Match3DFeedbackEvent = {
id: string;
kind: 'cleared' | 'rejected';
itemIds: string[];
};
type Match3DVisualSeed = (typeof MATCH3D_VISUAL_SEEDS)[number];
type Match3DGeometryShape =
| 'circle'
| 'triangle'
| 'diamond'
| 'square'
| 'star'
| 'hexagon'
| 'capsule'
| 'heart'
| 'trapezoid'
| 'parallelogram';
type Match3DGeometryAsset = {
shape: Match3DGeometryShape;
fill: string;
stroke: string;
};
const MATCH3D_RENDER_CENTER = 0.5;
const MATCH3D_RENDER_RADIUS = 0.5;
const MATCH3D_RENDER_SAFE_MARGIN = 0.035;
const MATCH3D_GEOMETRY_ASSETS: Record<string, Match3DGeometryAsset> = {
'watermelon-green': {
shape: 'circle',
fill: '#16a34a',
stroke: '#14532d',
},
'apple-red': {
shape: 'heart',
fill: '#ef4444',
stroke: '#991b1b',
},
'banana-yellow': {
shape: 'parallelogram',
fill: '#facc15',
stroke: '#a16207',
},
'grape-purple': {
shape: 'star',
fill: '#8b5cf6',
stroke: '#5b21b6',
},
'melon-green': {
shape: 'hexagon',
fill: '#84cc16',
stroke: '#3f6212',
},
'berry-blue': {
shape: 'diamond',
fill: '#2563eb',
stroke: '#1e3a8a',
},
'peach-pink': {
shape: 'trapezoid',
fill: '#fb7185',
stroke: '#be123c',
},
'plum-indigo': {
shape: 'capsule',
fill: '#4f46e5',
stroke: '#312e81',
},
'lime-lime': {
shape: 'square',
fill: '#65a30d',
stroke: '#365314',
},
'orange-orange': {
shape: 'triangle',
fill: '#f97316',
stroke: '#9a3412',
},
'pear-cyan': {
shape: 'parallelogram',
fill: '#06b6d4',
stroke: '#155e75',
},
red_circle: {
shape: 'circle',
fill: '#ef4444',
stroke: '#991b1b',
},
yellow_triangle: {
shape: 'triangle',
fill: '#facc15',
stroke: '#a16207',
},
purple_diamond: {
shape: 'diamond',
fill: '#7c3aed',
stroke: '#4c1d95',
},
green_square: {
shape: 'square',
fill: '#16a34a',
stroke: '#14532d',
},
blue_star: {
shape: 'star',
fill: '#0ea5e9',
stroke: '#075985',
},
orange_hexagon: {
shape: 'hexagon',
fill: '#f97316',
stroke: '#9a3412',
},
cyan_capsule: {
shape: 'capsule',
fill: '#06b6d4',
stroke: '#155e75',
},
pink_heart: {
shape: 'heart',
fill: '#ec4899',
stroke: '#9d174d',
},
lime_leaf: {
shape: 'trapezoid',
fill: '#84cc16',
stroke: '#3f6212',
},
white_moon: {
shape: 'parallelogram',
fill: '#e2e8f0',
stroke: '#64748b',
},
};
const MATCH3D_UNKNOWN_GEOMETRY_ASSETS: Match3DGeometryAsset[] = [
{ shape: 'circle', fill: '#f43f5e', stroke: '#9f1239' },
{ shape: 'triangle', fill: '#f59e0b', stroke: '#92400e' },
{ shape: 'diamond', fill: '#8b5cf6', stroke: '#5b21b6' },
{ shape: 'star', fill: '#10b981', stroke: '#065f46' },
{ shape: 'trapezoid', fill: '#0ea5e9', stroke: '#075985' },
{ shape: 'parallelogram', fill: '#14b8a6', stroke: '#115e59' },
];
const MATCH3D_UNKNOWN_VISUAL_SEEDS: Match3DVisualSeed[] = [
{
itemTypeId: 'unknown-rose',
visualKey: 'unknown-rose',
colorClassName: 'from-rose-400 to-red-600',
label: '一',
},
{
itemTypeId: 'unknown-amber',
visualKey: 'unknown-amber',
colorClassName: 'from-yellow-300 to-amber-500',
label: '二',
},
{
itemTypeId: 'unknown-violet',
visualKey: 'unknown-violet',
colorClassName: 'from-violet-400 to-purple-700',
label: '三',
},
{
itemTypeId: 'unknown-emerald',
visualKey: 'unknown-emerald',
colorClassName: 'from-emerald-300 to-green-600',
label: '四',
},
{
itemTypeId: 'unknown-sky',
visualKey: 'unknown-sky',
colorClassName: 'from-sky-300 to-blue-600',
label: '五',
},
];
function formatTimer(value: number) {
const totalSeconds = Math.max(0, Math.ceil(value / 1000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
function formatElapsed(
startedAtMs: number,
remainingMs: number,
durationLimitMs: number,
) {
const elapsedMs = Math.max(0, durationLimitMs - remainingMs);
const totalSeconds = Math.floor(elapsedMs / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
function hashVisualKey(visualKey: string) {
let hash = 0;
for (const char of visualKey) {
hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
}
return hash;
}
function resolveVisualSeed(visualKey: string) {
const knownSeed = MATCH3D_VISUAL_SEEDS.find(
(seed) => seed.visualKey === visualKey,
);
if (knownSeed) {
return knownSeed;
}
return MATCH3D_UNKNOWN_VISUAL_SEEDS[
hashVisualKey(visualKey) % MATCH3D_UNKNOWN_VISUAL_SEEDS.length
]!;
}
function resolveGeometryAsset(visualKey: string): Match3DGeometryAsset {
return (
MATCH3D_GEOMETRY_ASSETS[visualKey] ??
MATCH3D_UNKNOWN_GEOMETRY_ASSETS[
hashVisualKey(visualKey) % MATCH3D_UNKNOWN_GEOMETRY_ASSETS.length
]!
);
}
function renderGeometryShape(asset: Match3DGeometryAsset) {
const shapeProps = {
fill: asset.fill,
stroke: asset.stroke,
strokeWidth: 6,
strokeLinejoin: 'round' as const,
};
switch (asset.shape) {
case 'circle':
return <circle cx="50" cy="50" r="36" {...shapeProps} />;
case 'triangle':
return <path d="M50 12 L89 84 H11Z" {...shapeProps} />;
case 'diamond':
return <path d="M50 9 L91 50 L50 91 L9 50Z" {...shapeProps} />;
case 'square':
return <rect x="16" y="16" width="68" height="68" rx="8" {...shapeProps} />;
case 'star':
return (
<path
d="M50 8 L61 36 L91 38 L68 58 L76 88 L50 72 L24 88 L32 58 L9 38 L39 36Z"
{...shapeProps}
/>
);
case 'hexagon':
return <path d="M28 12 H72 L94 50 L72 88 H28 L6 50Z" {...shapeProps} />;
case 'capsule':
return <rect x="10" y="28" width="80" height="44" rx="22" {...shapeProps} />;
case 'heart':
return (
<path
d="M50 86 C25 66 13 52 17 34 C20 18 40 16 50 31 C60 16 80 18 83 34 C87 52 75 66 50 86Z"
{...shapeProps}
/>
);
case 'trapezoid':
return <path d="M27 18 H73 L90 82 H10Z" {...shapeProps} />;
case 'parallelogram':
return <path d="M34 16 H88 L66 84 H12Z" {...shapeProps} />;
default:
return <circle cx="50" cy="50" r="36" {...shapeProps} />;
}
}
function Match3DVisualIcon({
visualKey,
className = '',
}: {
visualKey: string;
className?: string;
}) {
const asset = resolveGeometryAsset(visualKey);
return (
<svg
className={`pointer-events-none h-full w-full drop-shadow-[0_5px_7px_rgba(15,23,42,0.36)] ${className}`}
viewBox="0 0 100 100"
aria-hidden
focusable={false}
data-testid={`match3d-visual-${visualKey}`}
data-shape={asset.shape}
>
{renderGeometryShape(asset)}
</svg>
);
}
function resolveRenderableItemFrame(item: Match3DItemSnapshot) {
const maxRadius = MATCH3D_RENDER_RADIUS - MATCH3D_RENDER_SAFE_MARGIN;
const radius = Math.min(
Math.max(Number.isFinite(item.radius) ? item.radius : 0.06, 0.035),
maxRadius,
);
const rawX = Number.isFinite(item.x) ? item.x : MATCH3D_RENDER_CENTER;
const rawY = Number.isFinite(item.y) ? item.y : MATCH3D_RENDER_CENTER;
const dx = rawX - MATCH3D_RENDER_CENTER;
const dy = rawY - MATCH3D_RENDER_CENTER;
const distance = Math.hypot(dx, dy);
const maxDistance = Math.max(
0,
MATCH3D_RENDER_RADIUS - MATCH3D_RENDER_SAFE_MARGIN - radius,
);
if (distance <= maxDistance || distance <= 0) {
return { x: rawX, y: rawY, radius };
}
const ratio = maxDistance / distance;
return {
x: MATCH3D_RENDER_CENTER + dx * ratio,
y: MATCH3D_RENDER_CENTER + dy * ratio,
radius,
};
}
function buildClientEventId(itemInstanceId: string) {
return `match3d-click-${itemInstanceId}-${Date.now()}-${Math.round(
Math.random() * 1_000_000,
)}`;
}
function isRunState(
status: Match3DRunSnapshot['status'],
expected: 'running' | 'won' | 'failed' | 'stopped',
) {
return String(status).toLowerCase() === expected;
}
function isItemState(
state: Match3DItemSnapshot['state'],
expected: 'in_board' | 'in_tray' | 'cleared' | 'flying',
) {
return (
String(state)
.replace(/([a-z])([A-Z])/gu, '$1_$2')
.toLowerCase() === expected
);
}
function isPointInsideCircle(
pointX: number,
pointY: number,
item: Match3DItemSnapshot,
) {
const frame = resolveRenderableItemFrame(item);
return Math.hypot(pointX - frame.x, pointY - frame.y) <= frame.radius;
}
function findHitItem(run: Match3DRunSnapshot, pointX: number, pointY: number) {
return run.items
.filter(
(item) =>
isItemState(item.state, 'in_board') &&
item.clickable &&
isPointInsideCircle(pointX, pointY, item),
)
.sort((left, right) => right.layer - left.layer)[0];
}
function buildOptimisticRun(
run: Match3DRunSnapshot,
item: Match3DItemSnapshot,
) {
const nextSlot = run.traySlots.find((slot) => !slot.itemInstanceId);
if (!nextSlot) {
return run;
}
return {
...run,
items: run.items.map((entry) =>
entry.itemInstanceId === item.itemInstanceId
? {
...entry,
state: 'Flying' as const,
clickable: false,
}
: entry,
),
traySlots: run.traySlots.map((slot) =>
slot.slotIndex === nextSlot.slotIndex
? {
slotIndex: slot.slotIndex,
itemInstanceId: item.itemInstanceId,
itemTypeId: item.itemTypeId,
visualKey: item.visualKey,
}
: slot,
),
};
}
function Match3DToken({
item,
disabled,
onClick,
}: {
item: Match3DItemSnapshot;
disabled: boolean;
onClick: (item: Match3DItemSnapshot) => void;
}) {
const visualSeed = resolveVisualSeed(item.visualKey);
const frame = resolveRenderableItemFrame(item);
const size = `${frame.radius * 200}%`;
const itemStateClass = isItemState(item.state, 'flying')
? 'scale-75 opacity-0'
: item.clickable
? 'cursor-pointer opacity-100 hover:scale-105 active:scale-95'
: 'opacity-48';
if (
!isItemState(item.state, 'in_board') &&
!isItemState(item.state, 'flying')
) {
return null;
}
return (
<button
type="button"
className={`absolute flex -translate-x-1/2 -translate-y-1/2 items-center justify-center bg-transparent p-0 transition-all duration-300 ${itemStateClass}`}
style={{
left: `${frame.x * 100}%`,
top: `${frame.y * 100}%`,
width: size,
height: size,
zIndex: item.layer + 10,
}}
aria-label={`${visualSeed.label} ${item.clickable ? '可点击' : '被遮挡'}`}
data-testid={`match3d-item-${item.itemInstanceId}`}
disabled={
disabled || !item.clickable || !isItemState(item.state, 'in_board')
}
onClick={() => onClick(item)}
>
<Match3DVisualIcon visualKey={item.visualKey} className="relative z-10" />
</button>
);
}
function Match3DTrayToken({ slot }: { slot: Match3DTraySlot }) {
if (!slot.visualKey) {
return (
<span className="h-full w-full rounded-xl border border-dashed border-slate-300/35 bg-white/8" />
);
}
const visualSeed = resolveVisualSeed(slot.visualKey);
return (
<span
className="flex h-full w-full items-center justify-center p-1"
aria-label={visualSeed.label}
>
<Match3DVisualIcon visualKey={slot.visualKey} />
</span>
);
}
function Match3DSettlement({
run,
onBack,
onRestart,
}: {
run: Match3DRunSnapshot;
onBack: () => void;
onRestart: () => void;
}) {
if (isRunState(run.status, 'running')) {
return null;
}
const won = isRunState(run.status, 'won');
const stopped = isRunState(run.status, 'stopped');
const title = won ? '通关完成' : stopped ? '已停止' : '本轮失败';
const description = won
? `用时 ${formatElapsed(run.startedAtMs, run.remainingMs, run.durationLimitMs)}`
: `已清除 ${run.clearedItemCount}/${run.totalItemCount}`;
return (
<div className="absolute inset-0 z-[80] flex items-center justify-center bg-slate-950/62 px-5 backdrop-blur-sm">
<section
className="w-full max-w-sm rounded-[1.5rem] border border-white/18 bg-white/94 p-5 text-slate-950 shadow-[0_26px_70px_rgba(15,23,42,0.34)]"
role="dialog"
aria-label={title}
>
<div className="mb-4 flex items-center gap-3">
<span
className={`flex h-11 w-11 items-center justify-center rounded-full ${
won
? 'bg-emerald-100 text-emerald-700'
: 'bg-rose-100 text-rose-700'
}`}
>
{won ? <CheckCircle2 size={24} /> : <XCircle size={24} />}
</span>
<div>
<h2 className="text-xl font-black">{title}</h2>
<p className="text-sm font-semibold text-slate-500">
{description}
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
className="rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm font-black text-slate-700"
onClick={onBack}
>
</button>
<button
type="button"
className="rounded-xl bg-slate-950 px-4 py-3 text-sm font-black text-white"
onClick={onRestart}
>
</button>
</div>
</section>
</div>
);
}
export function Match3DRuntimeShell({
run,
isBusy = false,
error = null,
onBack,
onRestart,
onOptimisticRunChange,
onClickItem,
onTimeExpired,
}: Match3DRuntimeShellProps) {
const stageRef = useRef<HTMLDivElement | null>(null);
const [pendingClick, setPendingClick] = useState<PendingClick | null>(null);
const [feedbackEvent, setFeedbackEvent] =
useState<Match3DFeedbackEvent | null>(null);
const [timeLeftMs, setTimeLeftMs] = useState(run?.remainingMs ?? 0);
useEffect(() => {
setTimeLeftMs(run?.remainingMs ?? 0);
}, [run?.remainingMs, run?.snapshotVersion]);
useEffect(() => {
if (!run || !isRunState(run.status, 'running')) {
return undefined;
}
const timer = window.setInterval(() => {
setTimeLeftMs((current) => {
const next = Math.max(0, current - 1000);
if (next <= 0) {
onTimeExpired?.();
}
return next;
});
}, 1000);
return () => window.clearInterval(timer);
}, [onTimeExpired, run]);
useEffect(() => {
if (!feedbackEvent) {
return undefined;
}
const timer = window.setTimeout(() => setFeedbackEvent(null), 520);
return () => window.clearTimeout(timer);
}, [feedbackEvent]);
const progressText = useMemo(() => {
if (!run) {
return '0/0';
}
return `${run.clearedItemCount}/${run.totalItemCount}`;
}, [run]);
const handleItemClick = async (item: Match3DItemSnapshot) => {
if (!run || !isRunState(run.status, 'running') || pendingClick) {
return;
}
const optimisticRun = buildOptimisticRun(run, item);
const clientEventId = buildClientEventId(item.itemInstanceId);
// 中文注释:先更新前端即时反馈,再等待后端确认;确认失败时用权威快照回滚校正。
setPendingClick({
clientEventId,
itemInstanceId: item.itemInstanceId,
previousRun: run,
});
onOptimisticRunChange(optimisticRun);
const result = await onClickItem({
runId: run.runId,
itemInstanceId: item.itemInstanceId,
clientSnapshotVersion: run.snapshotVersion,
clientEventId,
clickedAtMs: Date.now(),
});
if (result.status === 'Accepted') {
if (result.clearedItemInstanceIds.length > 0) {
setFeedbackEvent({
id: clientEventId,
kind: 'cleared',
itemIds: result.clearedItemInstanceIds,
});
}
onOptimisticRunChange(result.run);
} else {
setFeedbackEvent({
id: clientEventId,
kind: 'rejected',
itemIds: [item.itemInstanceId],
});
onOptimisticRunChange(result.run ?? run);
}
setPendingClick(null);
};
const handleBoardPointerDown = (event: PointerEvent<HTMLDivElement>) => {
if (!run || !isRunState(run.status, 'running') || pendingClick) {
return;
}
const rect = stageRef.current?.getBoundingClientRect();
if (!rect) {
return;
}
const pointX = (event.clientX - rect.left) / rect.width;
const pointY = (event.clientY - rect.top) / rect.height;
const item = findHitItem(run, pointX, pointY);
if (item) {
void handleItemClick(item);
}
};
if (!run) {
return (
<div className="flex min-h-dvh items-center justify-center bg-slate-950 text-white">
{isBusy ? '载入中' : (error ?? '暂无运行态')}
</div>
);
}
return (
<main className="relative flex min-h-dvh w-full justify-center overflow-hidden bg-[#16221f] text-white">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_12%,rgba(255,255,255,0.22),transparent_26%),linear-gradient(180deg,#b8e28d_0%,#377569_52%,#14201f_100%)]" />
<div className="relative flex min-h-dvh w-full max-w-md flex-col px-3 pb-[calc(env(safe-area-inset-bottom,0px)+0.8rem)] pt-[calc(env(safe-area-inset-top,0px)+0.65rem)]">
<header className="flex items-center justify-between gap-2">
<button
type="button"
className="flex h-10 w-10 items-center justify-center rounded-full border border-white/16 bg-black/22 text-white backdrop-blur"
onClick={onBack}
aria-label="返回"
>
<ArrowLeft size={20} />
</button>
<div className="flex items-center gap-2 rounded-full border border-white/16 bg-black/24 px-3 py-2 text-sm font-black backdrop-blur">
<Clock3 size={16} />
<span>{formatTimer(timeLeftMs)}</span>
</div>
<button
type="button"
className="flex h-10 w-10 items-center justify-center rounded-full border border-white/16 bg-black/22 text-white backdrop-blur"
onClick={onRestart}
aria-label="重新开始"
>
<RotateCcw size={18} />
</button>
</header>
<section className="mt-3 grid grid-cols-3 gap-2 text-center text-[0.72rem] font-black">
<div className="rounded-2xl border border-white/14 bg-black/18 px-2 py-2 backdrop-blur">
{progressText}
</div>
<div className="rounded-2xl border border-white/14 bg-black/18 px-2 py-2 backdrop-blur">
{run.clearCount}
</div>
<div className="rounded-2xl border border-white/14 bg-black/18 px-2 py-2 backdrop-blur">
v{run.snapshotVersion}
</div>
</section>
<section className="relative mt-3 flex flex-1 min-h-0 items-center justify-center">
<div
ref={stageRef}
className="relative aspect-square w-full max-w-[min(92vw,58dvh)] overflow-hidden rounded-full border-[10px] border-[#e6d19b] bg-[radial-gradient(circle_at_50%_42%,#f2d993_0%,#c88f43_56%,#835223_100%)] shadow-[inset_0_8px_34px_rgba(72,41,16,0.34),0_22px_42px_rgba(15,23,42,0.28)]"
onPointerDown={handleBoardPointerDown}
data-testid="match3d-board"
>
<div className="pointer-events-none absolute inset-[7%] z-0 rounded-full border border-white/22 bg-[radial-gradient(circle_at_44%_35%,rgba(255,255,255,0.22),transparent_28%)]" />
{run.items.map((item) => (
<Match3DToken
key={item.itemInstanceId}
item={item}
disabled={Boolean(pendingClick)}
onClick={handleItemClick}
/>
))}
{feedbackEvent?.kind === 'cleared' ? (
<div className="pointer-events-none absolute inset-0 z-[70] flex items-center justify-center">
<div className="flex h-24 w-24 items-center justify-center rounded-full bg-white/24 text-amber-100 shadow-[0_0_42px_rgba(255,255,255,0.72)] backdrop-blur-sm">
<Sparkles size={42} />
</div>
</div>
) : null}
</div>
</section>
<section className="mt-3 rounded-[1.35rem] border border-white/14 bg-black/24 p-2 shadow-[0_16px_36px_rgba(15,23,42,0.24)] backdrop-blur">
<div className="grid grid-cols-7 gap-1.5" data-testid="match3d-tray">
{run.traySlots.map((slot) => (
<div
key={slot.slotIndex}
className="aspect-square min-w-0 rounded-xl bg-white/10 p-1"
data-testid="match3d-tray-slot"
>
<Match3DTrayToken slot={slot} />
</div>
))}
</div>
</section>
</div>
{feedbackEvent?.kind === 'rejected' ? (
<div className="pointer-events-none absolute left-1/2 top-24 z-[90] -translate-x-1/2 rounded-full border border-rose-200/60 bg-rose-500/88 px-4 py-2 text-xs font-black text-white shadow-lg">
</div>
) : null}
<Match3DSettlement run={run} onBack={onBack} onRestart={onRestart} />
</main>
);
}
export default Match3DRuntimeShell;

View File

@@ -0,0 +1 @@
export { Match3DRuntimeShell } from './Match3DRuntimeShell';

View File

@@ -10,6 +10,7 @@ export interface PlatformEntryCreationTypeModalProps {
onClose: () => void;
onSelectRpg: () => void;
onSelectBigFish: () => void;
onSelectMatch3D: () => void;
onSelectPuzzle: () => void;
}
@@ -26,37 +27,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>
);
@@ -73,6 +72,7 @@ export function PlatformEntryCreationTypeModal({
onClose,
onSelectRpg,
onSelectBigFish,
onSelectMatch3D,
onSelectPuzzle,
}: PlatformEntryCreationTypeModalProps) {
if (!isOpen) {
@@ -105,6 +105,9 @@ export function PlatformEntryCreationTypeModal({
if (item.id === 'big-fish') {
onSelectBigFish();
}
if (item.id === 'match3d') {
onSelectMatch3D();
}
if (item.id === 'puzzle') {
onSelectPuzzle();
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,223 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { act } from 'react';
import { afterEach, expect, test, vi } from 'vitest';
import type { PlatformPuzzleGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation';
import { PlatformWorkDetailView } from './PlatformWorkDetailView';
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: ({
src,
alt,
className,
'aria-hidden': ariaHidden,
}: {
src?: string | null;
alt?: string;
className?: string;
'aria-hidden'?: boolean | 'true' | 'false';
}) => (
<img
src={src ?? ''}
alt={alt ?? ''}
className={className}
aria-hidden={ariaHidden}
/>
),
}));
function createPuzzleEntry(): PlatformPuzzleGalleryCard {
return {
sourceType: 'puzzle',
workId: 'work-1',
profileId: 'profile-1',
publicWorkCode: 'PZ-001',
ownerUserId: 'user-1',
authorDisplayName: '137****6613',
worldName: '关键词:逍遥游拼图',
subtitle: '拼图关卡',
summaryText: '适合公开游玩的拼图作品。',
coverImageSrc: null,
coverSlides: [],
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',
};
}
afterEach(() => {
vi.useRealTimers();
});
test('PlatformWorkDetailView renders compact stats and date time', () => {
render(
<PlatformWorkDetailView
entry={createPuzzleEntry()}
isBusy={false}
error={null}
onBack={vi.fn()}
onLike={vi.fn()}
onStart={vi.fn()}
onRemix={vi.fn()}
/>,
);
expect(screen.getByText('改造')).toBeTruthy();
expect(screen.getByText('游玩')).toBeTruthy();
expect(screen.getAllByText('点赞').length).toBeGreaterThanOrEqual(2);
expect(screen.getByText('日期')).toBeTruthy();
expect(screen.queryByText('改造次数')).toBeNull();
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();
expect(screen.getByRole('button', { name: '点赞 4赞' })).toBeTruthy();
expect(screen.getByRole('button', { name: '作品改造' })).toBeTruthy();
expect(screen.getByRole('button', { name: '启动' })).toBeTruthy();
});
test('PlatformWorkDetailView prefers resolved public user display name', () => {
render(
<PlatformWorkDetailView
entry={createPuzzleEntry()}
authorDisplayName="新的作者昵称"
isBusy={false}
error={null}
onBack={vi.fn()}
onLike={vi.fn()}
onStart={vi.fn()}
onRemix={vi.fn()}
/>,
);
expect(screen.getByText('新的作者昵称')).toBeTruthy();
expect(screen.queryByText('137****6613')).toBeNull();
});
test('PlatformWorkDetailView calls like handler', () => {
const onLike = vi.fn();
render(
<PlatformWorkDetailView
entry={createPuzzleEntry()}
isBusy={false}
error={null}
onBack={vi.fn()}
onLike={onLike}
onStart={vi.fn()}
onRemix={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '点赞 4赞' }));
expect(onLike).toHaveBeenCalledTimes(1);
});
test('PlatformWorkDetailView cycles puzzle level cover slides', () => {
vi.useFakeTimers();
const { container } = render(
<PlatformWorkDetailView
entry={{
...createPuzzleEntry(),
coverImageSrc: '/fallback-cover.png',
coverSlides: [
{
id: 'level-1',
imageSrc: '/level-1.png',
label: '第一关',
},
{
id: 'level-2',
imageSrc: '/level-2.png',
label: '第二关',
},
],
}}
isBusy={false}
error={null}
onBack={vi.fn()}
onLike={vi.fn()}
onStart={vi.fn()}
onRemix={vi.fn()}
/>,
);
expect(screen.getAllByAltText('关键词:逍遥游拼图')[0]?.getAttribute('src')).toBe(
'/level-1.png',
);
const appIconImage = container.querySelector(
'.platform-work-detail__app-icon img',
);
expect(appIconImage?.getAttribute('src')).toBe('/level-1.png');
fireEvent.click(screen.getByRole('button', { name: '下一张关卡图' }));
expect(screen.getAllByAltText('关键词:逍遥游拼图')[0]?.getAttribute('src')).toBe(
'/level-2.png',
);
expect(appIconImage?.getAttribute('src')).toBe('/level-1.png');
expect(
container.querySelector('.platform-work-detail__cover-image--locked'),
).toBeTruthy();
expect(
container.querySelector('.platform-work-detail__cover-lock-icon'),
).toBeTruthy();
act(() => {
vi.advanceTimersByTime(4200);
});
expect(screen.getAllByAltText('关键词:逍遥游拼图')[0]?.getAttribute('src')).toBe(
'/level-1.png',
);
});
test('PlatformWorkDetailView unlocks later puzzle covers by visible cover count', () => {
const { container } = render(
<PlatformWorkDetailView
entry={{
...createPuzzleEntry(),
coverSlides: [
{
id: 'level-1',
imageSrc: '/level-1.png',
label: '第一关',
},
{
id: 'level-2',
imageSrc: '/level-2.png',
label: '第二关',
},
],
}}
visibleCoverCount={2}
isBusy={false}
error={null}
onBack={vi.fn()}
onLike={vi.fn()}
onStart={vi.fn()}
onRemix={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '下一张关卡图' }));
expect(screen.getAllByAltText('关键词:逍遥游拼图')[0]?.getAttribute('src')).toBe(
'/level-2.png',
);
expect(
container.querySelector('.platform-work-detail__cover-image--locked'),
).toBeNull();
expect(
container.querySelector('.platform-work-detail__cover-lock-icon'),
).toBeNull();
});

View File

@@ -0,0 +1,443 @@
import {
ArrowLeft,
ChevronLeft,
ChevronRight,
CircleHelp,
Clock3,
Copy,
Gamepad2,
GitFork,
Heart,
Play,
Share2,
} from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { buildPublicWorkDetailUrl } from '../../routing/appPageRoutes';
import { copyTextToClipboard } from '../../services/clipboard';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import {
buildPlatformWorldDisplayTags,
formatPlatformWorkDisplayName,
formatPlatformWorkDisplayTags,
formatPlatformWorldTime,
type PlatformPublicGalleryCard,
resolvePlatformPublicWorkCode,
resolvePlatformWorldCoverSlides,
resolvePlatformWorldStats,
} from '../rpg-entry/rpgEntryWorldPresentation';
export interface PlatformWorkDetailViewProps {
entry: PlatformPublicGalleryCard;
authorAvatarUrl?: string | null;
authorDisplayName?: string | null;
isBusy: boolean;
error: string | null;
visibleCoverCount?: number;
onBack: () => void;
onLike: () => void;
onStart: () => void;
onRemix: () => void;
}
function formatCompactCount(value: number) {
if (value >= 10000) {
const normalized = value / 10000;
return `${Number.isInteger(normalized) ? normalized.toFixed(0) : normalized.toFixed(1)}`;
}
return `${value}`;
}
function getSourceLabel(entry: PlatformPublicGalleryCard) {
if ('sourceType' in entry && entry.sourceType === 'puzzle') {
return '拼图';
}
if ('sourceType' in entry && entry.sourceType === 'big-fish') {
return '大鱼吃小鱼';
}
if ('sourceType' in entry && entry.sourceType === 'match3d') {
return '抓大鹅';
}
return 'RPG';
}
function getAuthorAvatarLabel(authorDisplayName: string) {
return Array.from(authorDisplayName.trim() || '作')[0] ?? '作';
}
const PLATFORM_WORK_COVER_CAROUSEL_INTERVAL_MS = 4200;
export function PlatformWorkDetailView({
entry,
authorAvatarUrl,
authorDisplayName,
isBusy,
error,
visibleCoverCount = 1,
onBack,
onLike,
onStart,
onRemix,
}: PlatformWorkDetailViewProps) {
const coverSlides = useMemo(
() => resolvePlatformWorldCoverSlides(entry),
[entry],
);
const [activeCoverIndex, setActiveCoverIndex] = useState(0);
const activeCoverSlide =
coverSlides[activeCoverIndex] ?? coverSlides[0] ?? null;
const coverImage = activeCoverSlide?.imageSrc ?? '';
const unlockedCoverCount = Math.max(1, Math.floor(visibleCoverCount));
const isActiveCoverVisible = activeCoverIndex < unlockedCoverCount;
const appIconImage = coverSlides[0]?.imageSrc ?? '';
const hasCoverCarousel = coverSlides.length > 1;
const publicWorkCode = resolvePlatformPublicWorkCode(entry);
const normalizedAuthorAvatarUrl = authorAvatarUrl?.trim() ?? '';
const resolvedAuthorDisplayName =
authorDisplayName?.trim() || entry.authorDisplayName;
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
'idle',
);
const [shareState, setShareState] = useState<'idle' | 'copied' | 'failed'>(
'idle',
);
const displayName = formatPlatformWorkDisplayName(entry.worldName);
const tags = useMemo(
() =>
formatPlatformWorkDisplayTags(
[getSourceLabel(entry), ...buildPlatformWorldDisplayTags(entry, 3)],
4,
),
[entry],
);
const stats = resolvePlatformWorldStats(entry);
const statItems = [
{
label: '游玩',
value: formatCompactCount(stats.playCount),
unit: '次',
icon: Gamepad2,
tone: 'play',
},
{
label: '改造',
value: formatCompactCount(stats.remixCount),
unit: '次',
icon: GitFork,
tone: 'remix',
},
{
label: '点赞',
value: formatCompactCount(stats.likeCount),
unit: '赞',
icon: Heart,
tone: 'like',
},
{
label: '日期',
value: formatPlatformWorldTime(stats.updatedAt ?? stats.publishedAt),
icon: Clock3,
tone: 'time',
isTime: true,
},
];
useEffect(() => {
setActiveCoverIndex(0);
}, [entry.profileId, coverSlides.length]);
useEffect(() => {
setActiveCoverIndex((current) =>
coverSlides.length > 0 ? Math.min(current, coverSlides.length - 1) : 0,
);
}, [coverSlides.length]);
useEffect(() => {
if (!hasCoverCarousel) {
return undefined;
}
const timerId = window.setInterval(() => {
setActiveCoverIndex((current) => (current + 1) % coverSlides.length);
}, PLATFORM_WORK_COVER_CAROUSEL_INTERVAL_MS);
return () => {
window.clearInterval(timerId);
};
}, [coverSlides.length, hasCoverCarousel]);
const showPreviousCover = () => {
if (!hasCoverCarousel) {
return;
}
setActiveCoverIndex(
(current) => (current - 1 + coverSlides.length) % coverSlides.length,
);
};
const showNextCover = () => {
if (!hasCoverCarousel) {
return;
}
setActiveCoverIndex((current) => (current + 1) % coverSlides.length);
};
const copyPublicWorkCode = () => {
if (!publicWorkCode) {
return;
}
void copyTextToClipboard(publicWorkCode).then((copied) => {
setCopyState(copied ? 'copied' : 'failed');
window.setTimeout(() => setCopyState('idle'), 1400);
});
};
const sharePublicWork = () => {
if (!publicWorkCode) {
return;
}
const shareText = `邀请你来玩《${entry.worldName}\n作品号${publicWorkCode}\n${buildPublicWorkDetailUrl(publicWorkCode)}`;
void copyTextToClipboard(shareText).then((copied) => {
setShareState(copied ? 'copied' : 'failed');
window.setTimeout(() => setShareState('idle'), 1400);
});
};
return (
<div className="platform-work-detail">
<div className="platform-work-detail__topbar">
<button
type="button"
className="platform-work-detail__icon-button"
onClick={onBack}
aria-label="返回"
title="返回"
>
<ArrowLeft className="h-6 w-6" />
</button>
<div className="platform-work-detail__title"></div>
<button
type="button"
className="platform-work-detail__icon-button"
onClick={sharePublicWork}
disabled={!publicWorkCode}
aria-label="分享"
title="分享"
>
<Share2 className="h-5 w-5" />
</button>
</div>
<div className="platform-work-detail__scroll">
<section className="platform-work-detail__cover">
{coverImage ? (
<>
<ResolvedAssetImage
src={coverImage}
alt=""
aria-hidden="true"
className="platform-work-detail__cover-blur"
/>
<ResolvedAssetImage
src={coverImage}
alt={entry.worldName}
className={`platform-work-detail__cover-image${
isActiveCoverVisible
? ''
: ' platform-work-detail__cover-image--locked'
}`}
/>
{!isActiveCoverVisible ? (
<div
className="platform-work-detail__cover-lock"
aria-hidden="true"
>
<CircleHelp className="platform-work-detail__cover-lock-icon" />
</div>
) : null}
{hasCoverCarousel ? (
<>
<button
type="button"
className="platform-work-detail__cover-nav platform-work-detail__cover-nav--prev"
onClick={showPreviousCover}
aria-label="上一张关卡图"
title="上一张关卡图"
>
<ChevronLeft className="h-5 w-5" />
</button>
<button
type="button"
className="platform-work-detail__cover-nav platform-work-detail__cover-nav--next"
onClick={showNextCover}
aria-label="下一张关卡图"
title="下一张关卡图"
>
<ChevronRight className="h-5 w-5" />
</button>
<div className="platform-work-detail__cover-dots">
{coverSlides.map((slide, index) => (
<button
key={slide.id}
type="button"
className={`platform-work-detail__cover-dot${
index === activeCoverIndex
? ' platform-work-detail__cover-dot--active'
: ''
}`}
onClick={() => setActiveCoverIndex(index)}
aria-label={`查看${slide.label || `${index + 1}`}`}
aria-current={
index === activeCoverIndex ? 'true' : undefined
}
/>
))}
</div>
</>
) : null}
</>
) : (
<div className="platform-work-detail__cover-fallback" />
)}
</section>
<section className="platform-work-detail__summary">
<div className="platform-work-detail__meta-row">
<div className="platform-work-detail__app-icon">
{appIconImage ? (
<ResolvedAssetImage
src={appIconImage}
alt=""
aria-hidden="true"
className="h-full w-full object-cover"
/>
) : (
entry.worldName.slice(0, 1)
)}
</div>
<div className="min-w-0 flex-1">
<div className="platform-work-detail__name">{displayName}</div>
<div className="platform-work-detail__author">
<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(resolvedAuthorDisplayName)}
</span>
)}
</span>
<span className="platform-work-detail__author-name">
{resolvedAuthorDisplayName}
</span>
</div>
</div>
<button
type="button"
className="platform-work-detail__like"
onClick={onLike}
disabled={isBusy}
aria-label={`点赞 ${formatCompactCount(stats.likeCount)}`}
title="点赞"
>
<Heart className="h-5 w-5 fill-current" />
</button>
</div>
<div className="platform-work-detail__stats">
{statItems.map((item) => (
<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.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>
))}
</div>
</section>
<section className="platform-work-detail__body">
<div className="platform-work-detail__chips">
{tags.map((tag) => (
<span key={tag} className="platform-work-detail__chip">
{tag}
</span>
))}
</div>
<p className="platform-work-detail__copy">{entry.summaryText}</p>
{publicWorkCode ? (
<button
type="button"
className="platform-work-detail__code"
onClick={copyPublicWorkCode}
>
<Copy className="h-4 w-4" />
<span>{publicWorkCode}</span>
{copyState !== 'idle' ? (
<span>{copyState === 'copied' ? '已复制' : '复制失败'}</span>
) : null}
</button>
) : null}
{shareState !== 'idle' ? (
<div className="platform-work-detail__toast">
{shareState === 'copied' ? '分享内容已复制' : '分享失败'}
</div>
) : null}
{error ? (
<div className="platform-work-detail__error">{error}</div>
) : null}
</section>
</div>
<div className="platform-work-detail__bottom">
<button
type="button"
className="platform-work-detail__remix"
onClick={onRemix}
disabled={isBusy}
>
<GitFork className="h-5 w-5" />
</button>
<button
type="button"
className="platform-work-detail__start"
onClick={onStart}
disabled={isBusy}
>
<Play className="h-5 w-5 fill-current" />
</button>
</div>
</div>
);
}

View File

@@ -1,6 +1,7 @@
export type PlatformCreationTypeId =
| 'rpg'
| 'big-fish'
| 'match3d'
| 'puzzle'
| 'airp'
| 'visual-novel';
@@ -19,7 +20,15 @@ export type PlatformCreationTypeCard = {
* 平台层的入口、首屏卡带与初始化请求都应基于这份结果统一判断。
*/
export function getVisiblePlatformCreationTypes() {
return PLATFORM_CREATION_TYPES.filter((item) => !item.hidden);
const visibleCreationTypes = PLATFORM_CREATION_TYPES.filter(
(item) => !item.hidden,
);
// 中文注释:可创建模板优先露出,敬请期待模板后置;两组内部沿用配置顺序。
return [
...visibleCreationTypes.filter((item) => !item.locked),
...visibleCreationTypes.filter((item) => item.locked),
];
}
/**
@@ -36,10 +45,10 @@ export function isPlatformCreationTypeVisible(id: PlatformCreationTypeId) {
export const PLATFORM_CREATION_TYPES: PlatformCreationTypeCard[] = [
{
id: 'rpg',
title: '角色扮演 RPG',
subtitle: 'Agent 共创',
badge: '可创建',
locked: false,
title: '角色扮演',
subtitle: '敬请期待',
badge: '敬请期待',
locked: true,
},
{
id: 'big-fish',
@@ -51,8 +60,15 @@ export const PLATFORM_CREATION_TYPES: PlatformCreationTypeCard[] = [
},
{
id: 'puzzle',
title: '拼图玩法',
subtitle: '图像锚点共创',
title: '拼图',
subtitle: '创意礼物,生活分享',
badge: '可创建',
locked: false,
},
{
id: 'match3d',
title: '抓大鹅',
subtitle: '经典消除玩法',
badge: '可创建',
locked: false,
},
@@ -60,14 +76,14 @@ export const PLATFORM_CREATION_TYPES: PlatformCreationTypeCard[] = [
id: 'airp',
title: 'AIRP',
subtitle: '敬请期待',
badge: '锁定',
badge: '敬请期待',
locked: true,
},
{
id: 'visual-novel',
title: '视觉小说',
subtitle: '敬请期待',
badge: '锁定',
badge: '敬请期待',
locked: true,
},
];

View File

@@ -15,12 +15,16 @@ export type CustomWorldRuntimeLaunchOptions = {
export type SelectionStage =
| 'platform'
| 'work-detail'
| 'detail'
| 'agent-workspace'
| 'big-fish-agent-workspace'
| 'big-fish-generating'
| 'big-fish-result'
| 'big-fish-runtime'
| 'match3d-agent-workspace'
| 'match3d-result'
| 'match3d-runtime'
| 'puzzle-agent-workspace'
| 'puzzle-generating'
| 'puzzle-result'

View File

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

View File

@@ -1,8 +1,7 @@
/* @vitest-environment jsdom */
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, expect, test, vi } from 'vitest';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
import { PuzzleAgentWorkspace } from './PuzzleAgentWorkspace';
@@ -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,147 @@ beforeEach(() => {
}
});
test('puzzle workspace submits quick keyword fill request after two turns', async () => {
const user = userEvent.setup();
const onSubmitMessage = vi.fn();
afterEach(() => {
vi.useRealTimers();
});
test('puzzle workspace submits the work 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.change(screen.getByLabelText('画面描述'), {
target: { value: '一只猫在雨夜灯牌下回头。' },
});
fireEvent.click(screen.getByRole('button', { name: /稿/u }));
expect(onCreateFromForm).toHaveBeenCalledWith({
seedText: '暖灯猫街',
workTitle: '暖灯猫街',
workDescription: '一套雨夜猫街主题拼图。',
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();
const onCreateFromForm = vi.fn();
render(
<PuzzleAgentWorkspace
session={baseSession}
onBack={() => {}}
onSubmitMessage={onSubmitMessage}
onExecuteAction={() => {}}
onSubmitMessage={() => {}}
onExecuteAction={onExecuteAction}
onCreateFromForm={onCreateFromForm}
/>,
);
await user.click(screen.getByRole('button', { name: '补充剩余设定' }));
fireEvent.click(screen.getByRole('button', { name: /稿/u }));
expect(onSubmitMessage).toHaveBeenCalledWith(
expect.objectContaining({
text: '请补充剩余设定。',
quickFillRequested: true,
}),
);
expect(onCreateFromForm).not.toHaveBeenCalled();
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'compile_puzzle_draft',
promptText: '潮雾中的灯塔与断桥',
workTitle: '雾港遗迹拼图',
workDescription: '雾港遗迹拼图',
pictureDescription: '潮雾中的灯塔与断桥',
referenceImageSrc: null,
candidateCount: 1,
});
});
test('puzzle workspace hides keyword fill before two turns', () => {
test('puzzle workspace restores form draft fields and autosaves edits', () => {
vi.useFakeTimers();
const onAutoSaveForm = vi.fn();
const formDraftSession: PuzzleAgentSessionSnapshot = {
...baseSession,
seedText:
'作品名称:旧街拼图\n作品描述旧街雨夜的拼图草稿。\n画面描述旧街灯牌下的猫。',
draft: {
workTitle: '旧街拼图',
workDescription: '旧街雨夜的拼图草稿。',
levelName: '旧街灯牌',
summary: '旧街雨夜的拼图草稿。',
themeTags: ['旧街', '雨夜', '猫'],
forbiddenDirectives: [],
creatorIntent: null,
anchorPack: baseSession.anchorPack,
candidates: [],
selectedCandidateId: null,
coverImageSrc: null,
coverAssetId: null,
generationStatus: 'idle',
levels: [
{
levelId: 'puzzle-level-1',
levelName: '旧街灯牌',
pictureDescription: '旧街灯牌下的猫。',
candidates: [],
selectedCandidateId: null,
coverImageSrc: null,
coverAssetId: null,
generationStatus: 'idle',
},
],
formDraft: {
workTitle: '旧街拼图',
workDescription: '旧街雨夜的拼图草稿。',
pictureDescription: '旧街灯牌下的猫。',
},
},
};
render(
<PuzzleAgentWorkspace
session={{ ...baseSession, currentTurn: 1 }}
session={formDraftSession}
onBack={() => {}}
onSubmitMessage={() => {}}
onExecuteAction={() => {}}
onAutoSaveForm={onAutoSaveForm}
/>,
);
expect(screen.queryByRole('button', { name: '补充剩余设定' })).toBeNull();
});
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.getByLabelText('作品名称') as HTMLInputElement).value).toBe(
'旧街拼图',
);
expect((screen.getByLabelText('作品描述') as HTMLTextAreaElement).value).toBe(
'旧街雨夜的拼图草稿。',
);
expect((screen.getByLabelText('画面描述') as HTMLTextAreaElement).value).toBe(
'旧街灯牌下的猫。',
);
expect(screen.getByText('画面主体已经清楚,继续收束剩余关键词。')).toBeTruthy();
expect(screen.queryByText('拼图结果页草稿已生成。')).toBeNull();
fireEvent.change(screen.getByLabelText('画面描述'), {
target: { value: '旧街灯牌下的猫和发光雨伞。' },
});
act(() => {
vi.advanceTimersByTime(700);
});
expect(onAutoSaveForm).toHaveBeenCalledWith({
seedText: '旧街拼图',
workTitle: '旧街拼图',
workDescription: '旧街雨夜的拼图草稿。',
pictureDescription: '旧街灯牌下的猫和发光雨伞。',
referenceImageSrc: null,
});
});

View File

@@ -1,146 +1,423 @@
import { ArrowLeft, ImagePlus, Loader2, Sparkles, X } from 'lucide-react';
import { type ChangeEvent, useEffect, useMemo, useRef, 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';
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
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;
onAutoSaveForm?: (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 = {
workTitle: string;
workDescription: 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 = {
workTitle: '',
workDescription: '',
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 resolveInitialFormState(
session: PuzzleAgentSessionSnapshot | null,
initialFormPayload: CreatePuzzleAgentSessionRequest | null = null,
): PuzzleFormState {
const formDraft = session?.draft?.formDraft;
if (formDraft) {
return {
workTitle: formDraft.workTitle ?? '',
workDescription: formDraft.workDescription ?? '',
pictureDescription: formDraft.pictureDescription ?? '',
referenceImageSrc: initialFormPayload?.referenceImageSrc ?? '',
referenceImageLabel: initialFormPayload?.referenceImageSrc
? '已选择参考图'
: '',
};
}
function mapPuzzleOperation(
operation: PuzzleAgentOperationRecord | null | undefined,
): CreationAgentOperationView | null {
if (!operation) {
return null;
if (initialFormPayload) {
return {
workTitle:
initialFormPayload.workTitle ?? initialFormPayload.seedText ?? '',
workDescription: initialFormPayload.workDescription ?? '',
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,
workTitle:
session.draft?.workTitle ||
session.draft?.levelName ||
session.seedText ||
session.anchorPack.themePromise.value ||
session.messages.find((message) => message.role === 'user')?.text ||
'',
workDescription:
session.draft?.workDescription ||
session.anchorPack.themePromise.value ||
'',
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,
onAutoSaveForm,
initialFormPayload = null,
}: PuzzleAgentWorkspaceProps) {
const [formState, setFormState] = useState<PuzzleFormState>(() =>
resolveInitialFormState(session, initialFormPayload),
);
const [referenceImageError, setReferenceImageError] = useState<string | null>(
null,
);
const previousSessionIdRef = useRef<string | null>(
session?.sessionId ?? null,
);
const appliedInitialFormKeyRef = useRef<string | null>(null);
useEffect(() => {
const currentSessionId = session?.sessionId ?? null;
if (
currentSessionId &&
previousSessionIdRef.current === null &&
appliedInitialFormKeyRef.current ===
JSON.stringify(initialFormPayload ?? null)
) {
previousSessionIdRef.current = currentSessionId;
return;
}
previousSessionIdRef.current = currentSessionId;
const nextInitialFormKey =
currentSessionId ?? JSON.stringify(initialFormPayload ?? null);
if (appliedInitialFormKeyRef.current === nextInitialFormKey) {
return;
}
appliedInitialFormKeyRef.current = nextInitialFormKey;
setFormState(resolveInitialFormState(session, initialFormPayload));
setReferenceImageError(null);
}, [initialFormPayload, session?.sessionId]);
const workTitle = formState.workTitle.trim();
const workDescription = formState.workDescription.trim();
const pictureDescription = formState.pictureDescription.trim();
const canSubmit =
Boolean(workTitle && workDescription && pictureDescription) && !isBusy;
const autosavePayload = useMemo(
() => ({
seedText: workTitle,
workTitle,
workDescription,
pictureDescription,
referenceImageSrc: formState.referenceImageSrc || null,
}),
[
formState.referenceImageSrc,
pictureDescription,
workDescription,
workTitle,
],
);
const autosaveSignature = JSON.stringify([
autosavePayload.workTitle,
autosavePayload.workDescription,
autosavePayload.pictureDescription,
]);
const lastAutosaveSignatureRef = useRef(autosaveSignature);
const autosaveSessionIdRef = useRef(session?.sessionId ?? null);
useEffect(() => {
const currentSessionId = session?.sessionId ?? null;
if (autosaveSessionIdRef.current === currentSessionId) {
return;
}
autosaveSessionIdRef.current = currentSessionId;
lastAutosaveSignatureRef.current = autosaveSignature;
}, [autosaveSignature, session?.sessionId]);
useEffect(() => {
if (
!session ||
session.stage !== 'collecting_anchors' ||
!session.draft?.formDraft ||
!onAutoSaveForm ||
lastAutosaveSignatureRef.current === autosaveSignature
) {
return;
}
const timer = window.setTimeout(() => {
lastAutosaveSignatureRef.current = autosaveSignature;
onAutoSaveForm(autosavePayload);
}, 700);
return () => window.clearTimeout(timer);
}, [
autosavePayload,
autosaveSignature,
onAutoSaveForm,
session?.draft?.formDraft,
session?.stage,
session?.sessionId,
]);
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: workTitle,
workTitle,
workDescription,
pictureDescription,
referenceImageSrc: formState.referenceImageSrc || null,
};
if (!session && onCreateFromForm) {
onCreateFromForm(payload);
return;
}
onExecuteAction({
action: 'compile_puzzle_draft',
promptText: pictureDescription,
workTitle,
workDescription,
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.workTitle}
disabled={isBusy}
onChange={(event) =>
setFormState((current) => ({
...current,
workTitle: 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>
<textarea
value={formState.workDescription}
disabled={isBusy}
rows={4}
onChange={(event) =>
setFormState((current) => ({
...current,
workDescription: event.target.value,
}))
}
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
aria-label="作品描述"
/>
</label>
<label className="block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<div className="relative mt-2">
<textarea
value={formState.pictureDescription}
disabled={isBusy}
rows={10}
onChange={(event) =>
setFormState((current) => ({
...current,
pictureDescription: event.target.value,
}))
}
className="w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 pb-16 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
aria-label="画面描述"
/>
<label
className={`absolute bottom-3 right-3 inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-full border border-amber-300/70 bg-white/96 text-amber-700 shadow-sm transition hover:bg-amber-50 ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
title={
formState.referenceImageSrc ? '更换参考图' : '添加参考图'
}
>
<ImagePlus className="h-4 w-4" />
<span className="sr-only">
{formState.referenceImageSrc ? '更换参考图' : '添加参考图'}
</span>
<input
type="file"
accept="image/png,image/jpeg,image/webp"
disabled={isBusy}
onChange={(event) => {
void handleReferenceImageChange(event);
}}
className="hidden"
/>
</label>
</div>
</label>
{formState.referenceImageSrc ? (
<div className="flex items-center gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-3">
<div className="h-14 w-14 overflow-hidden rounded-[0.9rem] bg-[var(--platform-subpanel-fill)]">
<img
src={formState.referenceImageSrc}
alt="拼图参考图"
className="h-full w-full object-cover"
/>
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-[var(--platform-text-strong)]">
{formState.referenceImageLabel || '已选择参考图'}
</div>
</div>
<button
type="button"
disabled={isBusy}
onClick={() => {
setFormState((current) => ({
...current,
referenceImageSrc: '',
referenceImageLabel: '',
}));
setReferenceImageError(null);
}}
className="platform-icon-button h-9 w-9"
aria-label="移除参考图"
title="移除参考图"
>
<X className="h-4 w-4" />
</button>
</div>
) : null}
{referenceImageError ? (
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
{referenceImageError}
</div>
) : null}
{error ? (
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
{error}
</div>
) : null}
</div>
</section>
</div>
<div className="mt-4 flex justify-end pb-[max(0.25rem,env(safe-area-inset-bottom))]">
<button
type="button"
disabled={!canSubmit}
onClick={submitForm}
className={`platform-button platform-button--primary ${!canSubmit ? 'cursor-not-allowed opacity-55' : ''}`}
>
<span className="inline-flex items-center gap-2">
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
<Sparkles className="h-4 w-4" />
稿
</span>
</button>
</div>
</div>
);
}

View File

@@ -2,13 +2,22 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { act } from 'react';
import { afterEach, expect, test, vi } from 'vitest';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import { PuzzleGalleryDetailView } from './PuzzleGalleryDetailView';
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: () => null,
ResolvedAssetImage: ({
src,
alt,
className,
}: {
src?: string | null;
alt?: string;
className?: string;
}) => <img src={src ?? ''} alt={alt ?? ''} className={className} />,
}));
const originalClipboard = navigator.clipboard;
@@ -28,10 +37,12 @@ const detailItem = {
updatedAt: '2026-04-25T10:00:00.000Z',
publishedAt: '2026-04-25T10:00:00.000Z',
playCount: 7,
likeCount: 0,
publishReady: true,
} satisfies PuzzleWorkSummary;
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
@@ -39,6 +50,72 @@ afterEach(() => {
});
});
test('cycles every level image on puzzle detail cover', async () => {
vi.useFakeTimers();
render(
<PuzzleGalleryDetailView
item={{
...detailItem,
coverImageSrc: '/fallback-cover.png',
levels: [
{
levelId: 'level-1',
levelName: '第一关',
pictureDescription: '第一关画面',
selectedCandidateId: 'candidate-1',
coverImageSrc: '/level-1-cover.png',
coverAssetId: null,
generationStatus: 'ready',
candidates: [
{
candidateId: 'candidate-1',
imageSrc: '/level-1.png',
assetId: 'asset-1',
prompt: '',
sourceType: 'generated',
selected: true,
},
],
},
{
levelId: 'level-2',
levelName: '第二关',
pictureDescription: '第二关画面',
selectedCandidateId: null,
coverImageSrc: '/level-2.png',
coverAssetId: null,
generationStatus: 'ready',
candidates: [],
},
],
}}
onBack={vi.fn()}
onStartGame={vi.fn()}
/>,
);
expect(screen.getByAltText('第一关').getAttribute('src')).toBe(
'/level-1.png',
);
act(() => {
screen.getByRole('button', { name: '下一张关卡图' }).click();
});
expect(screen.getByAltText('第二关').getAttribute('src')).toBe(
'/level-2.png',
);
act(() => {
vi.advanceTimersByTime(4200);
});
expect(screen.getByAltText('第一关').getAttribute('src')).toBe(
'/level-1.png',
);
});
test('shows and copies puzzle public work code in detail view', async () => {
const user = userEvent.setup();
const writeText = vi.fn(async () => undefined);

View File

@@ -1,11 +1,25 @@
import { ArrowLeft, Copy, Pencil, Play, Share2, UserRound } from 'lucide-react';
import { useState } from 'react';
import {
ArrowLeft,
ChevronLeft,
ChevronRight,
Copy,
Pencil,
Play,
Share2,
UserRound,
} from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
import { copyTextToClipboard } from '../../services/clipboard';
import { buildPuzzlePublicWorkCode } from '../../services/publicWorkCode';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import {
buildPuzzleWorkCoverSlides,
formatPlatformWorkDisplayName,
formatPlatformWorkDisplayTags,
} from '../rpg-entry/rpgEntryWorldPresentation';
type PuzzleGalleryDetailViewProps = {
item: PuzzleWorkSummary;
@@ -16,6 +30,8 @@ type PuzzleGalleryDetailViewProps = {
onStartGame: () => void;
};
const PUZZLE_DETAIL_COVER_CAROUSEL_INTERVAL_MS = 4200;
/**
* 拼图广场详情页。
* 展示最小信息并提供进入游戏动作,不扩展评论、收藏等非本轮需求。
@@ -35,6 +51,55 @@ export function PuzzleGalleryDetailView({
const [shareState, setShareState] = useState<'idle' | 'copied' | 'failed'>(
'idle',
);
const displayName = formatPlatformWorkDisplayName(item.levelName);
const displayTags = formatPlatformWorkDisplayTags(item.themeTags);
const coverSlides = useMemo(() => buildPuzzleWorkCoverSlides(item), [item]);
const [activeCoverIndex, setActiveCoverIndex] = useState(0);
const activeCoverSlide =
coverSlides[activeCoverIndex] ?? coverSlides[0] ?? null;
const coverImageSrc = activeCoverSlide?.imageSrc ?? '';
const hasCoverCarousel = coverSlides.length > 1;
useEffect(() => {
setActiveCoverIndex(0);
}, [item.profileId, coverSlides.length]);
useEffect(() => {
setActiveCoverIndex((current) =>
coverSlides.length > 0 ? Math.min(current, coverSlides.length - 1) : 0,
);
}, [coverSlides.length]);
useEffect(() => {
if (!hasCoverCarousel) {
return undefined;
}
const timerId = window.setInterval(() => {
setActiveCoverIndex((current) => (current + 1) % coverSlides.length);
}, PUZZLE_DETAIL_COVER_CAROUSEL_INTERVAL_MS);
return () => {
window.clearInterval(timerId);
};
}, [coverSlides.length, hasCoverCarousel]);
const showPreviousCover = () => {
if (!hasCoverCarousel) {
return;
}
setActiveCoverIndex(
(current) => (current - 1 + coverSlides.length) % coverSlides.length,
);
};
const showNextCover = () => {
if (!hasCoverCarousel) {
return;
}
setActiveCoverIndex((current) => (current + 1) % coverSlides.length);
};
const copyPublicWorkCode = () => {
void copyTextToClipboard(publicWorkCode).then((copied) => {
setCopyState(copied ? 'copied' : 'failed');
@@ -109,7 +174,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">
@@ -145,13 +210,55 @@ export function PuzzleGalleryDetailView({
<div className="grid min-h-0 flex-1 gap-3 overflow-hidden lg:grid-cols-[minmax(0,1.05fr)_minmax(18rem,0.95fr)]">
<section className="min-h-0 overflow-hidden rounded-[1.5rem] border border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)]">
<div className="aspect-square overflow-hidden">
{item.coverImageSrc ? (
<ResolvedAssetImage
src={item.coverImageSrc}
alt={item.levelName}
className="h-full w-full object-cover"
/>
<div className="relative aspect-square overflow-hidden">
{coverImageSrc ? (
<>
<ResolvedAssetImage
src={coverImageSrc}
alt={activeCoverSlide?.label || item.levelName}
className="h-full w-full object-cover"
/>
{hasCoverCarousel ? (
<>
<button
type="button"
onClick={showPreviousCover}
className="absolute left-3 top-1/2 z-10 inline-flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full border border-white/30 bg-slate-950/36 text-white backdrop-blur"
aria-label="上一张关卡图"
title="上一张关卡图"
>
<ChevronLeft className="h-5 w-5" />
</button>
<button
type="button"
onClick={showNextCover}
className="absolute right-3 top-1/2 z-10 inline-flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full border border-white/30 bg-slate-950/36 text-white backdrop-blur"
aria-label="下一张关卡图"
title="下一张关卡图"
>
<ChevronRight className="h-5 w-5" />
</button>
<div className="absolute inset-x-4 bottom-3 z-10 flex justify-center gap-1.5">
{coverSlides.map((slide, index) => (
<button
key={slide.id}
type="button"
onClick={() => setActiveCoverIndex(index)}
className={`h-1.5 rounded-full bg-white transition-all ${
index === activeCoverIndex
? 'w-5 opacity-95'
: 'w-1.5 opacity-48'
}`}
aria-label={`查看${slide.label || `${index + 1}`}`}
aria-current={
index === activeCoverIndex ? 'true' : undefined
}
/>
))}
</div>
</>
) : null}
</>
) : (
<div className="flex h-full items-center justify-center 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))] text-sm text-white/66">
@@ -166,7 +273,7 @@ export function PuzzleGalleryDetailView({
</div>
<div className="mt-3 flex flex-wrap gap-2">
{item.themeTags.map((tag) => (
{displayTags.map((tag) => (
<span
key={tag}
className="rounded-full border border-amber-300/35 bg-amber-100/68 px-3 py-1 text-xs font-semibold text-amber-700"

View File

@@ -11,8 +11,8 @@ import {
import { afterEach, describe, expect, test, vi } from 'vitest';
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
import { puzzleAssetClient } from '../../services/puzzle-works/puzzleAssetClient';
import * as puzzleWorksService from '../../services/puzzle-works';
import { puzzleAssetClient } from '../../services/puzzle-works/puzzleAssetClient';
import { PuzzleResultView } from './PuzzleResultView';
vi.mock('../ResolvedAssetImage', () => ({
@@ -45,96 +45,79 @@ afterEach(() => {
function createSession(
overrides: Partial<PuzzleAgentSessionSnapshot> = {},
): PuzzleAgentSessionSnapshot {
const anchorPack = {
themePromise: {
key: 'themePromise',
label: '题材承诺',
value: '雨夜猫咪',
status: 'confirmed' as const,
},
visualSubject: {
key: 'visualSubject',
label: '画面主体',
value: '屋檐下的猫',
status: 'confirmed' as const,
},
visualMood: {
key: 'visualMood',
label: '视觉气质',
value: '温暖',
status: 'confirmed' as const,
},
compositionHooks: {
key: 'compositionHooks',
label: '拼图记忆点',
value: '雨滴与灯牌',
status: 'confirmed' as const,
},
tagsAndForbidden: {
key: 'tagsAndForbidden',
label: '标签与禁忌',
value: '猫咪、雨夜',
status: 'confirmed' as const,
},
};
const level = {
levelId: 'puzzle-level-1',
levelName: '雨夜猫街',
pictureDescription: '屋檐下的猫与暖灯街角。',
candidates: [
{
candidateId: 'candidate-1',
imageSrc: '/puzzle/candidate-1.png',
assetId: 'asset-1',
prompt: '雨夜猫咪',
actualPrompt: null,
sourceType: 'generated' as const,
selected: true,
},
],
selectedCandidateId: 'candidate-1',
coverImageSrc: '/puzzle/candidate-1.png',
coverAssetId: 'asset-1',
generationStatus: 'ready' as const,
};
const baseSession: PuzzleAgentSessionSnapshot = {
sessionId: 'puzzle-session-1',
currentTurn: 2,
progressPercent: 88,
stage: 'ready_to_publish',
anchorPack: {
themePromise: {
key: 'themePromise',
label: '题材承诺',
value: '雨夜猫咪',
status: 'confirmed',
},
visualSubject: {
key: 'visualSubject',
label: '画面主体',
value: '屋檐下的猫',
status: 'confirmed',
},
visualMood: {
key: 'visualMood',
label: '视觉气质',
value: '温暖',
status: 'confirmed',
},
compositionHooks: {
key: 'compositionHooks',
label: '拼图记忆点',
value: '雨滴与灯牌',
status: 'confirmed',
},
tagsAndForbidden: {
key: 'tagsAndForbidden',
label: '标签与禁忌',
value: '猫咪、雨夜',
status: 'confirmed',
},
},
anchorPack,
draft: {
levelName: '雨夜猫街',
summary: '屋檐下的猫与暖灯街角。',
themeTags: ['猫咪', '雨夜'],
workTitle: '暖灯猫街作品',
workDescription: '一套雨夜猫街主题拼图。',
levelName: level.levelName,
summary: level.pictureDescription,
themeTags: ['猫咪', '雨夜', '暖灯'],
forbiddenDirectives: [],
creatorIntent: null,
anchorPack: {
themePromise: {
key: 'themePromise',
label: '题材承诺',
value: '雨夜猫咪',
status: 'confirmed',
},
visualSubject: {
key: 'visualSubject',
label: '画面主体',
value: '屋檐下的猫',
status: 'confirmed',
},
visualMood: {
key: 'visualMood',
label: '视觉气质',
value: '温暖',
status: 'confirmed',
},
compositionHooks: {
key: 'compositionHooks',
label: '拼图记忆点',
value: '雨滴与灯牌',
status: 'confirmed',
},
tagsAndForbidden: {
key: 'tagsAndForbidden',
label: '标签与禁忌',
value: '猫咪、雨夜',
status: 'confirmed',
},
},
candidates: [
{
candidateId: 'candidate-1',
imageSrc: '/puzzle/candidate-1.png',
assetId: 'asset-1',
prompt: '雨夜猫咪',
actualPrompt: null,
sourceType: 'generated',
selected: true,
},
],
selectedCandidateId: 'candidate-1',
coverImageSrc: '/puzzle/candidate-1.png',
coverAssetId: 'asset-1',
anchorPack,
candidates: level.candidates,
selectedCandidateId: level.selectedCandidateId,
coverImageSrc: level.coverImageSrc,
coverAssetId: level.coverAssetId,
generationStatus: 'ready',
levels: [level],
metadata: null,
},
messages: [],
@@ -160,40 +143,7 @@ function createSession(
}
describe('PuzzleResultView', () => {
test('auto saves renamed title 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.getByDisplayValue('雨夜猫街'), {
target: { value: '暖灯猫街' },
});
await act(async () => {
await vi.runAllTimersAsync();
});
expect(puzzleWorksService.updatePuzzleWork).toHaveBeenCalledWith(
'puzzle-profile-session-1',
expect.objectContaining({
levelName: '暖灯猫街',
summary: '屋檐下的猫与暖灯街角。',
themeTags: ['猫咪', '雨夜'],
}),
);
});
test('uses two tabs without author preview or persistent publish validation', () => {
test('renders level list and work info tabs', () => {
render(
<PuzzleResultView
session={createSession()}
@@ -203,135 +153,23 @@ describe('PuzzleResultView', () => {
/>,
);
expect(screen.getByRole('button', { name: '基本信息' })).toBeTruthy();
expect(screen.getByRole('button', { name: '拼图图片' })).toBeTruthy();
expect(screen.queryByText('作者预览')).toBeNull();
expect(screen.queryByText('发布校验')).toBeNull();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
});
expect(screen.getByRole('button', { name: '拼图关卡' })).toBeTruthy();
expect(screen.getByRole('button', { name: '作品信息' })).toBeTruthy();
expect(screen.getByText('雨夜猫街')).toBeTruthy();
expect(screen.getByText('获得更多积分激励')).toBeTruthy();
test('edits theme tags with chips instead of a persistent tag input', () => {
vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({
item: {} as never,
});
render(
<PuzzleResultView
session={createSession()}
profileId="puzzle-profile-session-1"
onBack={() => {}}
onExecuteAction={() => {}}
/>,
fireEvent.click(screen.getByRole('button', { name: '作品信息' }));
expect(screen.getByLabelText('作品名称')).toHaveProperty(
'value',
'暖灯猫街作品',
);
expect(screen.queryByLabelText('新题材标签')).toBeNull();
fireEvent.click(screen.getByLabelText('删除标签 猫咪'));
expect(screen.queryByText('猫咪')).toBeNull();
expect(screen.getByText('雨夜')).toBeTruthy();
fireEvent.click(screen.getByLabelText('新增题材标签'));
fireEvent.change(screen.getByLabelText('新题材标签'), {
target: { value: '暖灯' },
});
fireEvent.click(screen.getByRole('button', { name: '添加' }));
expect(screen.getByText('暖灯')).toBeTruthy();
expect(screen.queryByLabelText('新题材标签')).toBeNull();
});
test('shows blockers only after clicking publish and blocks publish action', () => {
const onExecuteAction = vi.fn();
render(
<PuzzleResultView
session={createSession({
resultPreview: {
draft: createSession().draft!,
publishReady: false,
blockers: [
{
id: 'missing-cover',
code: 'missing-cover',
message: '请先选择正式图',
},
],
qualityFindings: [],
},
})}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
expect(screen.queryByText('请先选择正式图')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: //u }));
const dialog = screen.getByRole('dialog', { name: '发布拼图作品' });
expect(within(dialog).getByText('请先选择正式图')).toBeTruthy();
fireEvent.click(within(dialog).getByRole('button', { name: '发布到广场' }));
expect(onExecuteAction).not.toHaveBeenCalled();
});
test('starts work test from the current editable draft', () => {
const onStartTestRun = vi.fn();
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={() => {}}
onStartTestRun={onStartTestRun}
/>,
);
fireEvent.change(screen.getByDisplayValue('雨夜猫街'), {
target: { value: '暖灯猫街' },
});
fireEvent.click(screen.getByLabelText('新增题材标签'));
fireEvent.change(screen.getByLabelText('新题材标签'), {
target: { value: '暖灯' },
});
fireEvent.click(screen.getByRole('button', { name: '添加' }));
fireEvent.click(screen.getByRole('button', { name: //u }));
expect(onStartTestRun).toHaveBeenCalledWith(
expect.objectContaining({
levelName: '暖灯猫街',
themeTags: ['猫咪', '雨夜', '暖灯'],
}),
expect(screen.getByLabelText('作品描述')).toHaveProperty(
'value',
'一套雨夜猫街主题拼图。',
);
});
test('requires at least three theme tags before publish can pass', () => {
const onExecuteAction = vi.fn();
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
fireEvent.click(screen.getByLabelText('删除标签 猫咪'));
fireEvent.click(screen.getByRole('button', { name: //u }));
const dialog = screen.getByRole('dialog', { name: '发布拼图作品' });
expect(
within(dialog).getByText('正式标签数量必须在 3 到 6 之间。'),
).toBeTruthy();
expect(
(
within(dialog).getByRole('button', {
name: '发布到广场',
}) as HTMLButtonElement
).disabled,
).toBe(true);
});
test('auto saves added and removed theme tags', async () => {
test('auto saves work info and levels through one payload', async () => {
vi.useFakeTimers();
vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({
item: {} as never,
@@ -346,37 +184,158 @@ describe('PuzzleResultView', () => {
/>,
);
fireEvent.click(screen.getByLabelText('新增题材标签'));
fireEvent.change(screen.getByLabelText('新题材标签'), {
target: { value: '暖灯' },
fireEvent.click(screen.getByRole('button', { name: '作品信息' }));
fireEvent.change(screen.getByLabelText('作品名称'), {
target: { value: '暖灯猫街合集' },
});
fireEvent.click(screen.getByRole('button', { name: '添加' }));
await act(async () => {
await vi.runAllTimersAsync();
});
expect(puzzleWorksService.updatePuzzleWork).toHaveBeenLastCalledWith(
expect(puzzleWorksService.updatePuzzleWork).toHaveBeenCalledWith(
'puzzle-profile-session-1',
expect.objectContaining({
workTitle: '暖灯猫街合集',
workDescription: '一套雨夜猫街主题拼图。',
levelName: '雨夜猫街',
summary: '屋檐下的猫与暖灯街角。',
themeTags: ['猫咪', '雨夜', '暖灯'],
}),
);
fireEvent.click(screen.getByLabelText('删除标签 猫咪'));
await act(async () => {
await vi.runAllTimersAsync();
});
expect(puzzleWorksService.updatePuzzleWork).toHaveBeenLastCalledWith(
'puzzle-profile-session-1',
expect.objectContaining({
themeTags: ['雨夜', '暖灯'],
levels: expect.arrayContaining([
expect.objectContaining({
levelId: 'puzzle-level-1',
levelName: '雨夜猫街',
}),
]),
}),
);
});
test('generates one image from the picture description and replaces current image', () => {
test('opens an independent level detail dialog for generation and test play', () => {
const onExecuteAction = vi.fn();
const onStartTestRun = vi.fn();
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={onExecuteAction}
onStartTestRun={onStartTestRun}
/>,
);
fireEvent.click(screen.getByText('雨夜猫街'));
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
fireEvent.change(within(dialog).getByLabelText('关卡名称'), {
target: { value: '暖灯猫街' },
});
fireEvent.change(within(dialog).getByLabelText('画面描述'), {
target: { value: '一只猫在雨夜灯牌下回头。' },
});
fireEvent.click(within(dialog).getByRole('button', { name: //u }));
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'generate_puzzle_images',
levelId: 'puzzle-level-1',
promptText: '一只猫在雨夜灯牌下回头。',
referenceImageSrc: undefined,
candidateCount: 1,
levelsJson: expect.any(String),
});
const generatePayload = onExecuteAction.mock.calls[0]![0];
expect(JSON.parse(generatePayload.levelsJson ?? '[]')).toEqual([
expect.objectContaining({
levelId: 'puzzle-level-1',
levelName: '暖灯猫街',
pictureDescription: '一只猫在雨夜灯牌下回头。',
}),
]);
const levelNameInput = within(dialog).getByLabelText('关卡名称');
const formalImageTitle = within(dialog).getByText('画面图');
const pictureDescriptionInput = within(dialog).getByLabelText('画面描述');
expect(
levelNameInput.compareDocumentPosition(formalImageTitle) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(
formalImageTitle.compareDocumentPosition(pictureDescriptionInput) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
fireEvent.click(within(dialog).getByRole('button', { name: //u }));
expect(onStartTestRun).toHaveBeenCalledWith(
expect.objectContaining({
levelName: '暖灯猫街',
summary: '一只猫在雨夜灯牌下回头。',
levels: [
expect.objectContaining({
levelId: 'puzzle-level-1',
levelName: '暖灯猫街',
}),
],
}),
);
});
test('adds and deletes levels from the list', async () => {
vi.useFakeTimers();
vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({
item: {} as never,
});
render(
<PuzzleResultView
session={createSession()}
profileId="puzzle-profile-session-1"
onBack={() => {}}
onExecuteAction={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: //u }));
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
expect(within(dialog).getByRole('button', { name: //u })).toBeTruthy();
expect(within(dialog).queryByText('画面图')).toBeNull();
expect(within(dialog).queryByRole('button', { name: //u })).toBeNull();
fireEvent.click(screen.getByLabelText('关闭'));
expect(screen.getAllByText('第2关').length).toBeGreaterThan(0);
await act(async () => {
await vi.runAllTimersAsync();
});
expect(puzzleWorksService.updatePuzzleWork).toHaveBeenLastCalledWith(
'puzzle-profile-session-1',
expect.objectContaining({
levels: expect.arrayContaining([
expect.objectContaining({ levelId: 'puzzle-level-1' }),
expect.objectContaining({ levelName: '' }),
]),
}),
);
fireEvent.click(screen.getByLabelText('删除关卡 第2关'));
expect(screen.queryByText('第2关')).toBeNull();
await act(async () => {
await vi.runAllTimersAsync();
});
expect(puzzleWorksService.updatePuzzleWork).toHaveBeenLastCalledWith(
'puzzle-profile-session-1',
expect.objectContaining({
levels: [
expect.objectContaining({
levelId: 'puzzle-level-1',
}),
],
}),
);
});
test('generates image for a newly added level with the current levels snapshot', () => {
vi.spyOn(Date, 'now').mockReturnValue(1_775_000_000_000);
const onExecuteAction = vi.fn();
render(
@@ -387,24 +346,72 @@ describe('PuzzleResultView', () => {
/>,
);
fireEvent.click(screen.getByRole('button', { name: '拼图图片' }));
expect(screen.getByText('画面描述')).toBeTruthy();
expect(screen.queryByText(//u)).toBeNull();
fireEvent.change(screen.getByLabelText('画面描述'), {
target: { value: '一只猫在雨夜灯牌下回头。' },
fireEvent.click(screen.getByRole('button', { name: //u }));
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
fireEvent.change(within(dialog).getByLabelText('画面描述'), {
target: { value: '新关卡里有一座发光钟楼。' },
});
fireEvent.click(screen.getByRole('button', { name: //u }));
fireEvent.click(within(dialog).getByRole('button', { name: //u }));
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'generate_puzzle_images',
promptText: '一只猫在雨夜灯牌下回头。',
levelId: 'puzzle-level-1775000000000-2',
promptText: '新关卡里有一座发光钟楼。',
referenceImageSrc: undefined,
candidateCount: 1,
levelsJson: expect.any(String),
});
const payload = onExecuteAction.mock.calls[0]![0];
expect(JSON.parse(payload.levelsJson ?? '[]')).toEqual([
expect.objectContaining({ levelId: 'puzzle-level-1' }),
expect.objectContaining({
levelId: 'puzzle-level-1775000000000-2',
levelName: '',
pictureDescription: '新关卡里有一座发光钟楼。',
}),
]);
});
test('selects a history puzzle asset as reference image for the next generation', async () => {
test('publishes with work info and serialized levels', () => {
const onExecuteAction = vi.fn();
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
fireEvent.click(screen.getByRole('button', { name: //u }));
fireEvent.click(
within(screen.getByRole('dialog', { name: '发布拼图作品' })).getByRole(
'button',
{ name: '发布到广场' },
),
);
expect(onExecuteAction).toHaveBeenCalledWith(
expect.objectContaining({
action: 'publish_puzzle_work',
workTitle: '暖灯猫街作品',
workDescription: '一套雨夜猫街主题拼图。',
levelName: '雨夜猫街',
summary: '屋檐下的猫与暖灯街角。',
themeTags: ['猫咪', '雨夜', '暖灯'],
}),
);
const payload = onExecuteAction.mock.calls[0]![0];
expect(JSON.parse(payload.levelsJson)).toEqual([
expect.objectContaining({
levelId: 'puzzle-level-1',
levelName: '雨夜猫街',
}),
]);
});
test('selects a history puzzle asset as reference image for the selected level', async () => {
const onExecuteAction = vi.fn();
vi.mocked(puzzleAssetClient.listHistoryAssets).mockResolvedValue([
{
@@ -428,118 +435,30 @@ describe('PuzzleResultView', () => {
/>,
);
fireEvent.click(screen.getByRole('button', { name: '拼图图片' }));
fireEvent.click(screen.getByText('雨夜猫街'));
fireEvent.click(screen.getByLabelText('从历史拼图素材库选择'));
const dialog = await screen.findByRole('dialog', {
const picker = await screen.findByRole('dialog', {
name: '选择历史拼图素材',
});
fireEvent.click(within(dialog).getByRole('button', { name: / user-1/u }));
fireEvent.click(
await within(picker).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',
levelId: 'puzzle-level-1',
promptText: '屋檐下的猫与暖灯街角。',
referenceImageSrc: '/generated-puzzle-assets/history/image.png',
candidateCount: 1,
});
});
test('refreshes the current formal image when session cover image changes', async () => {
const { rerender } = render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '拼图图片' }));
expect(screen.getByRole('img', { name: '雨夜猫街' }).getAttribute('src')).toBe(
'/puzzle/candidate-1.png',
);
rerender(
<PuzzleResultView
session={createSession({
draft: {
...createSession().draft!,
candidates: [
{
candidateId: 'candidate-2',
imageSrc: '/puzzle/candidate-2.png',
assetId: 'asset-2',
prompt: '新图',
actualPrompt: '新图',
sourceType: 'generated',
selected: true,
},
],
selectedCandidateId: 'candidate-2',
coverImageSrc: '/puzzle/candidate-2.png',
coverAssetId: 'asset-2',
},
updatedAt: '2026-04-27T11:11:11.000Z',
})}
onBack={() => {}}
onExecuteAction={() => {}}
/>,
);
await waitFor(() => {
expect(screen.getByRole('img', { name: '雨夜猫街' }).getAttribute('src')).toBe(
'/puzzle/candidate-2.png',
);
});
});
test('prefers the selected latest candidate image when coverImageSrc lags behind', async () => {
render(
<PuzzleResultView
session={createSession({
draft: {
...createSession().draft!,
candidates: [
{
candidateId: 'candidate-1',
imageSrc: '/puzzle/candidate-1.png',
assetId: 'asset-1',
prompt: '旧图',
actualPrompt: '旧图',
sourceType: 'generated',
selected: false,
},
{
candidateId: 'candidate-2',
imageSrc: '/puzzle/candidate-2.png',
assetId: 'asset-2',
prompt: '新图',
actualPrompt: '新图',
sourceType: 'generated',
selected: true,
},
],
selectedCandidateId: 'candidate-2',
coverImageSrc: '/puzzle/candidate-1.png',
coverAssetId: 'asset-1',
},
})}
onBack={() => {}}
onExecuteAction={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '拼图图片' }));
await waitFor(() => {
expect(screen.getByRole('img', { name: '雨夜猫街' }).getAttribute('src')).toBe(
'/puzzle/candidate-2.png',
);
levelsJson: expect.any(String),
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +1,21 @@
/* @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';
import { AuthUiContext } from '../auth/AuthUiContext';
import {
PuzzleRuntimeShell,
buildMergedGroupOutlinePath,
resolveDraggedMergedGroupLayer,
resolveDraggedPieceCellLayer,
resolveDraggedPieceLayer,
} from './PuzzleRuntimeShell';
} from './puzzleRuntimeShape';
import { PuzzleRuntimeShell } from './PuzzleRuntimeShell';
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
useResolvedAssetReadUrl: () => ({
resolvedUrl: '',
useResolvedAssetReadUrl: (src: string | null) => ({
resolvedUrl: src ?? '',
isResolving: false,
shouldResolve: false,
}),
@@ -32,6 +33,7 @@ function createAuthValue() {
requireAuth: (action: () => void) => action(),
openSettingsModal: () => {},
openAccountModal: () => {},
setCurrentUser: vi.fn(),
logout: async () => {},
musicVolume: 0.42,
setMusicVolume: vi.fn(),
@@ -52,6 +54,16 @@ function renderPuzzleRuntime(
);
}
function dispatchPointerEvent(
target: HTMLElement,
type: string,
options: { pointerId: number; clientX: number; clientY: number },
) {
const event = new Event(type, { bubbles: true, cancelable: true });
Object.assign(event, options);
target.dispatchEvent(event);
}
const clearedRun: PuzzleRunSnapshot = {
runId: 'run-1',
entryProfileId: 'profile-1',
@@ -77,6 +89,7 @@ const clearedRun: PuzzleRunSnapshot = {
currentLevel: {
runId: 'run-1',
levelIndex: 1,
levelId: 'puzzle-level-1',
gridSize: 3,
profileId: 'profile-1',
levelName: '潮雾拼图',
@@ -87,6 +100,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,
@@ -151,12 +171,167 @@ test('通关后显示结算弹窗、排行榜和下一关按钮', () => {
vi.useRealTimers();
});
test('关闭通关弹窗后保留底部下一关入口', () => {
vi.useFakeTimers();
test('顶部作者显示头像昵称,底部功能居中放大且不显示等待候选', () => {
const runWithoutNext: PuzzleRunSnapshot = {
...clearedRun,
recommendedNextProfileId: null,
};
renderPuzzleRuntime(
<PuzzleRuntimeShell
run={clearedRun}
run={runWithoutNext}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
onAdvanceNextLevel={vi.fn()}
/>,
);
const avatar = screen.getByText('测');
const timer = screen.getByText('4:48');
const hintButton = screen.getByRole('button', { name: '提示' });
const referenceButton = screen.getByRole('button', { name: '原图' });
const freezeButton = screen.getByRole('button', { name: '冻结' });
expect(avatar.className).toContain('rounded-full');
expect(screen.getByText('测试作者')).toBeTruthy();
expect(timer.className).toContain('text-2xl');
expect(hintButton.className).toContain('h-16');
expect(referenceButton.className).toContain('h-16');
expect(freezeButton.className).toContain('h-16');
expect(screen.queryByText('等待下一关候选')).toBeNull();
});
test('关闭通关弹窗后保留底部下一关入口', () => {
vi.useFakeTimers();
const onAdvanceNextLevel = vi.fn();
const runWithoutRecommendedNextProfile: PuzzleRunSnapshot = {
...clearedRun,
recommendedNextProfileId: null,
nextLevelMode: 'sameWork',
nextLevelProfileId: 'profile-1',
nextLevelId: 'puzzle-level-2',
recommendedNextWorks: [],
};
renderPuzzleRuntime(
<PuzzleRuntimeShell
run={runWithoutRecommendedNextProfile}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
onAdvanceNextLevel={onAdvanceNextLevel}
/>,
);
act(() => {
vi.advanceTimersByTime(1_400);
});
fireEvent.click(screen.getByRole('button', { name: '关闭通关弹窗' }));
expect(screen.queryByRole('dialog', { name: '通关完成' })).toBeNull();
const nextButton = screen.getByRole('button', { name: //u });
expect(nextButton).toBeTruthy();
fireEvent.click(nextButton);
expect(onAdvanceNextLevel).toHaveBeenCalledTimes(1);
expect(onAdvanceNextLevel).toHaveBeenCalledWith({
profileId: 'profile-1',
levelId: 'puzzle-level-2',
});
vi.useRealTimers();
});
test('当前作品没有下一关时展示三个相似作品并可选择进入', () => {
vi.useFakeTimers();
const onAdvanceNextLevel = vi.fn();
const similarWorksRun: PuzzleRunSnapshot = {
...clearedRun,
recommendedNextProfileId: 'profile-similar-1',
nextLevelMode: 'similarWorks',
nextLevelProfileId: 'profile-similar-1',
nextLevelId: null,
recommendedNextWorks: [
{
profileId: 'profile-similar-1',
levelName: '雾海遗迹',
authorDisplayName: '星桥旅人',
themeTags: ['奇幻', '遗迹'],
coverImageSrc: null,
similarityScore: 0.91,
},
{
profileId: 'profile-similar-2',
levelName: '风塔试炼',
authorDisplayName: '晨风',
themeTags: ['奇幻', '机关'],
coverImageSrc: null,
similarityScore: 0.84,
},
{
profileId: 'profile-similar-3',
levelName: '月井秘路',
authorDisplayName: '月井守望',
themeTags: ['秘境', '魔法'],
coverImageSrc: null,
similarityScore: 0.79,
},
],
};
renderPuzzleRuntime(
<PuzzleRuntimeShell
run={similarWorksRun}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
onAdvanceNextLevel={onAdvanceNextLevel}
/>,
);
act(() => {
vi.advanceTimersByTime(1_400);
});
const dialog = screen.getByRole('dialog', { name: '通关完成' });
expect(within(dialog).getByText('雾海遗迹')).toBeTruthy();
expect(within(dialog).getByText('风塔试炼')).toBeTruthy();
expect(within(dialog).getByText('月井秘路')).toBeTruthy();
expect(within(dialog).queryByRole('button', { name: '下一关' })).toBeNull();
fireEvent.click(within(dialog).getByRole('button', { name: //u }));
expect(onAdvanceNextLevel).toHaveBeenCalledTimes(1);
expect(onAdvanceNextLevel).toHaveBeenCalledWith({
profileId: 'profile-similar-2',
});
vi.useRealTimers();
});
test('当前作品没有下一关时底部入口打开相似作品选择', () => {
vi.useFakeTimers();
const similarWorksRun: PuzzleRunSnapshot = {
...clearedRun,
recommendedNextProfileId: 'profile-similar-1',
nextLevelMode: 'similarWorks',
nextLevelProfileId: 'profile-similar-1',
nextLevelId: null,
recommendedNextWorks: [
{
profileId: 'profile-similar-1',
levelName: '雾海遗迹',
authorDisplayName: '星桥旅人',
themeTags: ['奇幻', '遗迹'],
coverImageSrc: null,
similarityScore: 0.91,
},
],
};
renderPuzzleRuntime(
<PuzzleRuntimeShell
run={similarWorksRun}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
@@ -169,8 +344,10 @@ test('关闭通关弹窗后保留底部下一关入口', () => {
});
fireEvent.click(screen.getByRole('button', { name: '关闭通关弹窗' }));
expect(screen.queryByRole('dialog', { name: '通关完成' })).toBeNull();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: //u }));
expect(screen.getByRole('dialog', { name: '通关完成' })).toBeTruthy();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
vi.useRealTimers();
});
@@ -198,12 +375,44 @@ test('右上角设置按钮打开拼图设置并支持音量调节', () => {
expect(authValue.setMusicVolume).toHaveBeenCalledWith(0.77);
});
test('拼图棋盘使用贴近移动端边缘的正方形舞台承载切块', () => {
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-square');
expect(board.className).toContain('max-w-[min(99vw,calc(100vh_-_16.5rem))]');
expect(board.className).not.toContain('aspect-video');
expect(board.className).not.toContain('aspect-[9/16]');
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,
@@ -242,21 +451,518 @@ test('合并块按实际拼块外轮廓描边', () => {
expect(outlinedPieces).toHaveLength(3);
expect(container.querySelector('.ring-2.ring-emerald-100\\/58')).toBeNull();
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[1]?.className).toContain('border-l-0');
expect(outlinedPieces[1]?.className).toContain('rounded-tr-[0.85rem]');
expect(outlinedPieces[2]?.className).toContain('border-t-0');
expect(outlinedPieces[2]?.className).toContain('rounded-bl-[0.85rem]');
expect(
container.querySelector('[data-merged-group-outline="true"]'),
).toBeTruthy();
const outlineStroke = container.querySelector(
'[data-merged-group-outline-stroke="true"]',
);
expect(outlineStroke).toBeTruthy();
expect(outlineStroke?.getAttribute('d')).toContain('Q 2 1 1.84 1');
expect(outlineStroke?.getAttribute('d')).toContain('Q 1 1 1 1.16');
expect((outlinedPieces[0] as HTMLElement).style.clipPath).toBe('');
const clippedLayer = container.querySelector(
'[style*="clip-path"]',
) as HTMLElement | null;
expect(clippedLayer?.style.clipPath).toContain('url(#');
});
test('合并块轮廓路径为内凹角生成圆角曲线', () => {
const outlinePath = buildMergedGroupOutlinePath({
rowSpan: 2,
colSpan: 2,
pieces: [
{
localRow: 0,
localCol: 0,
},
{
localRow: 0,
localCol: 1,
},
{
localRow: 1,
localCol: 0,
},
],
});
expect(outlinePath).toContain('Q 2 1 1.84 1');
expect(outlinePath).toContain('Q 1 1 1 1.16');
});
test('基础单块使用圆角裁剪图片', () => {
const playingRun: PuzzleRunSnapshot = {
...clearedRun,
currentLevel: {
...clearedRun.currentLevel!,
status: 'playing',
startedAtMs: Date.now(),
coverImageSrc: '/puzzle.png',
board: {
...clearedRun.currentLevel!.board,
allTilesResolved: false,
},
},
};
const { container } = renderPuzzleRuntime(
<PuzzleRuntimeShell
run={playingRun}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
onAdvanceNextLevel={vi.fn()}
/>,
);
const basePiece = container.querySelector(
'[data-piece-id="piece-0"]',
) as HTMLElement | null;
expect(basePiece?.className).toContain('overflow-hidden');
expect(basePiece?.className).toContain('rounded-[0.85rem]');
});
test('移动端点击拼图片时立即触发一次震动反馈', () => {
const originalVibrate = navigator.vibrate;
const originalRequestAnimationFrame = window.requestAnimationFrame;
const originalCancelAnimationFrame = window.cancelAnimationFrame;
const vibrate = vi.fn();
const playingRun: PuzzleRunSnapshot = {
...clearedRun,
currentLevel: {
...clearedRun.currentLevel!,
status: 'playing',
startedAtMs: Date.now(),
board: {
...clearedRun.currentLevel!.board,
allTilesResolved: false,
},
},
};
Object.defineProperty(navigator, 'vibrate', {
configurable: true,
value: vibrate,
});
Object.defineProperty(window, 'requestAnimationFrame', {
configurable: true,
value: vi.fn(() => 1),
});
Object.defineProperty(window, 'cancelAnimationFrame', {
configurable: true,
value: vi.fn(),
});
const { container, unmount } = renderPuzzleRuntime(
<PuzzleRuntimeShell
run={playingRun}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
onAdvanceNextLevel={vi.fn()}
/>,
);
const piece = container.querySelector(
'[data-piece-id="piece-0"]',
) as HTMLElement | null;
if (!piece) {
throw new Error('缺少测试拼图片');
}
act(() => {
dispatchPointerEvent(piece, 'pointerdown', {
pointerId: 1,
clientX: 100,
clientY: 100,
});
});
expect(vibrate).toHaveBeenCalledTimes(1);
expect(vibrate).toHaveBeenCalledWith([12]);
act(() => {
dispatchPointerEvent(piece, 'pointermove', {
pointerId: 1,
clientX: 104,
clientY: 104,
});
});
expect(vibrate).toHaveBeenCalledTimes(1);
act(() => {
dispatchPointerEvent(piece, 'pointermove', {
pointerId: 1,
clientX: 112,
clientY: 100,
});
});
act(() => {
dispatchPointerEvent(piece, 'pointermove', {
pointerId: 1,
clientX: 132,
clientY: 100,
});
});
expect(vibrate).toHaveBeenCalledTimes(1);
unmount();
Object.defineProperty(navigator, 'vibrate', {
configurable: true,
value: originalVibrate,
});
Object.defineProperty(window, 'requestAnimationFrame', {
configurable: true,
value: originalRequestAnimationFrame,
});
Object.defineProperty(window, 'cancelAnimationFrame', {
configurable: true,
value: originalCancelAnimationFrame,
});
});
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('冻结确认期间后端同步失败态时关闭确认窗并展示失败面板', async () => {
const onUseProp = vi.fn().mockResolvedValue({
...clearedRun,
currentLevel: {
...clearedRun.currentLevel!,
status: 'failed',
elapsedMs: 180_000,
remainingMs: 0,
board: {
...clearedRun.currentLevel!.board,
allTilesResolved: false,
},
},
});
const playingRun: PuzzleRunSnapshot = {
...clearedRun,
currentLevel: {
...clearedRun.currentLevel!,
status: 'playing',
startedAtMs: Date.now(),
remainingMs: 180_000,
board: {
...clearedRun.currentLevel!.board,
allTilesResolved: false,
},
},
};
const { rerender } = renderPuzzleRuntime(
<PuzzleRuntimeShell
run={playingRun}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
onAdvanceNextLevel={vi.fn()}
onUseProp={onUseProp}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '冻结' }));
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: '确定' }));
});
rerender(
<AuthUiContext.Provider value={createAuthValue()}>
<PuzzleRuntimeShell
run={{
...playingRun,
currentLevel: {
...playingRun.currentLevel!,
status: 'failed',
elapsedMs: 180_000,
remainingMs: 0,
},
}}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
onAdvanceNextLevel={vi.fn()}
onUseProp={onUseProp}
/>
</AuthUiContext.Provider>,
);
expect(screen.queryByRole('dialog', { name: '冻结时间' })).toBeNull();
expect(screen.getByRole('dialog', { name: '关卡失败' })).toBeTruthy();
expect(screen.queryByTestId('puzzle-freeze-effect')).toBeNull();
});
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 onRestartLevel = vi.fn();
const onUseProp = vi.fn().mockResolvedValue({
...clearedRun,
currentLevel: {
...clearedRun.currentLevel!,
status: 'playing',
remainingMs: 60_000,
},
});
const failedRun: PuzzleRunSnapshot = {
...clearedRun,
currentLevel: {
...clearedRun.currentLevel!,
status: 'failed',
elapsedMs: 180_000,
remainingMs: 0,
board: {
...clearedRun.currentLevel!.board,
allTilesResolved: false,
},
},
};
renderPuzzleRuntime(
<PuzzleRuntimeShell
run={failedRun}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
onAdvanceNextLevel={vi.fn()}
onRestartLevel={onRestartLevel}
onUseProp={onUseProp}
/>,
);
const failedDialog = screen.getByRole('dialog', { name: '关卡失败' });
fireEvent.click(within(failedDialog).getByRole('button', { name: '重新开始' }));
expect(onRestartLevel).toHaveBeenCalledTimes(1);
fireEvent.click(
within(failedDialog).getByRole('button', { name: '继续1分钟' }),
);
expect(screen.getByRole('dialog', { name: '继续1分钟' })).toBeTruthy();
expect(screen.getByText('消耗 1 光点')).toBeTruthy();
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: '确定' }));
});
expect(onUseProp).toHaveBeenCalledWith('extendTime');
});
test('失败续时扣费失败时保留确认弹窗', async () => {
const onUseProp = vi.fn().mockRejectedValue(new Error('光点余额不足'));
const failedRun: PuzzleRunSnapshot = {
...clearedRun,
currentLevel: {
...clearedRun.currentLevel!,
status: 'failed',
elapsedMs: 180_000,
remainingMs: 0,
board: {
...clearedRun.currentLevel!.board,
allTilesResolved: false,
},
},
};
renderPuzzleRuntime(
<PuzzleRuntimeShell
run={failedRun}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
onAdvanceNextLevel={vi.fn()}
onUseProp={onUseProp}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '继续1分钟' }));
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: '确定' }));
});
expect(screen.getByRole('dialog', { name: '继续1分钟' })).toBeTruthy();
expect(screen.getByText('光点余额不足')).toBeTruthy();
});
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();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,276 @@
type PuzzleMergedGroupShape = {
colSpan: number;
rowSpan: number;
pieces: Array<{
localRow: number;
localCol: number;
}>;
};
type GridPoint = {
x: number;
y: number;
};
type GridEdge = {
start: GridPoint;
end: GridPoint;
};
const MERGED_GROUP_OUTLINE_CORNER_RADIUS = 0.16;
function buildLocalCellKey(row: number, col: number) {
return `${row}:${col}`;
}
function formatSvgNumber(value: number) {
const normalizedValue = Object.is(value, -0) ? 0 : value;
return Number(normalizedValue.toFixed(4)).toString();
}
function formatSvgPoint(point: GridPoint) {
return `${formatSvgNumber(point.x)} ${formatSvgNumber(point.y)}`;
}
function gridPointKey(point: GridPoint) {
return `${formatSvgNumber(point.x)}:${formatSvgNumber(point.y)}`;
}
function distanceBetweenGridPoints(first: GridPoint, second: GridPoint) {
return Math.hypot(second.x - first.x, second.y - first.y);
}
function moveGridPointToward(
from: GridPoint,
target: GridPoint,
distance: number,
) {
const fullDistance = distanceBetweenGridPoints(from, target);
if (fullDistance <= 0) {
return from;
}
const ratio = Math.min(1, distance / fullDistance);
return {
x: from.x + (target.x - from.x) * ratio,
y: from.y + (target.y - from.y) * ratio,
};
}
function isCollinearGridCorner(
previous: GridPoint,
current: GridPoint,
next: GridPoint,
) {
return (
(previous.x === current.x && current.x === next.x) ||
(previous.y === current.y && current.y === next.y)
);
}
function removeCollinearGridPoints(points: GridPoint[]) {
if (points.length <= 3) {
return points;
}
return points.filter((point, index) => {
const previous = points[(index - 1 + points.length) % points.length];
const next = points[(index + 1) % points.length];
return previous && next && !isCollinearGridCorner(previous, point, next);
});
}
function buildRoundedGridCyclePath(
points: GridPoint[],
radius: number,
transformPoint: (point: GridPoint) => GridPoint = (point) => point,
) {
const cyclePoints = removeCollinearGridPoints(points);
if (cyclePoints.length < 3) {
return '';
}
const resolveCorner = (index: number) => {
const point = cyclePoints[index];
const previous = cyclePoints[
(index - 1 + cyclePoints.length) % cyclePoints.length
];
const next = cyclePoints[(index + 1) % cyclePoints.length];
if (!point || !previous || !next) {
return null;
}
const safeRadius = Math.min(
radius,
distanceBetweenGridPoints(point, previous) / 2,
distanceBetweenGridPoints(point, next) / 2,
);
return {
point,
entry: moveGridPointToward(point, previous, safeRadius),
exit: moveGridPointToward(point, next, safeRadius),
};
};
const firstCorner = resolveCorner(0);
if (!firstCorner) {
return '';
}
const commands = [`M ${formatSvgPoint(transformPoint(firstCorner.exit))}`];
for (let index = 1; index <= cyclePoints.length; index += 1) {
const corner = resolveCorner(index % cyclePoints.length);
if (!corner) {
continue;
}
commands.push(`L ${formatSvgPoint(transformPoint(corner.entry))}`);
commands.push(
`Q ${formatSvgPoint(transformPoint(corner.point))} ${formatSvgPoint(
transformPoint(corner.exit),
)}`,
);
}
commands.push('Z');
return commands.join(' ');
}
function buildMergedGroupBoundaryCycles(group: PuzzleMergedGroupShape) {
const groupCellKeys = new Set(
group.pieces.map((piece) =>
buildLocalCellKey(piece.localRow, piece.localCol),
),
);
const hasCell = (row: number, col: number) =>
groupCellKeys.has(buildLocalCellKey(row, col));
const edges: GridEdge[] = [];
for (const piece of group.pieces) {
const { localRow: row, localCol: col } = piece;
if (!hasCell(row - 1, col)) {
edges.push({ start: { x: col, y: row }, end: { x: col + 1, y: row } });
}
if (!hasCell(row, col + 1)) {
edges.push({
start: { x: col + 1, y: row },
end: { x: col + 1, y: row + 1 },
});
}
if (!hasCell(row + 1, col)) {
edges.push({
start: { x: col + 1, y: row + 1 },
end: { x: col, y: row + 1 },
});
}
if (!hasCell(row, col - 1)) {
edges.push({ start: { x: col, y: row + 1 }, end: { x: col, y: row } });
}
}
const edgeIndexesByStart = new Map<string, number[]>();
edges.forEach((edge, index) => {
const key = gridPointKey(edge.start);
const indexes = edgeIndexesByStart.get(key) ?? [];
indexes.push(index);
edgeIndexesByStart.set(key, indexes);
});
const unusedEdgeIndexes = new Set(edges.map((_, index) => index));
const cycles: GridPoint[][] = [];
while (unusedEdgeIndexes.size > 0) {
const firstEdgeIndex = unusedEdgeIndexes.values().next().value as
| number
| undefined;
if (firstEdgeIndex === undefined) {
break;
}
const firstEdge = edges[firstEdgeIndex];
if (!firstEdge) {
unusedEdgeIndexes.delete(firstEdgeIndex);
continue;
}
const cycle: GridPoint[] = [firstEdge.start];
let currentEdge = firstEdge;
unusedEdgeIndexes.delete(firstEdgeIndex);
for (let guard = 0; guard < edges.length + 1; guard += 1) {
const currentEnd = currentEdge.end;
const cycleStart = cycle[0];
if (!cycleStart || gridPointKey(currentEnd) === gridPointKey(cycleStart)) {
break;
}
cycle.push(currentEnd);
const nextEdgeIndex = (
edgeIndexesByStart.get(gridPointKey(currentEnd)) ?? []
).find((index) => unusedEdgeIndexes.has(index));
if (nextEdgeIndex === undefined) {
break;
}
const nextEdge = edges[nextEdgeIndex];
if (!nextEdge) {
unusedEdgeIndexes.delete(nextEdgeIndex);
break;
}
currentEdge = nextEdge;
unusedEdgeIndexes.delete(nextEdgeIndex);
}
if (cycle.length >= 3) {
cycles.push(cycle);
}
}
return cycles;
}
export function buildMergedGroupOutlinePath(
group: PuzzleMergedGroupShape,
radius = MERGED_GROUP_OUTLINE_CORNER_RADIUS,
) {
// 合并块的凹入角不能靠单格 border-radius 稳定拼出来,必须先生成整体外轮廓。
return buildMergedGroupBoundaryCycles(group)
.map((cycle) => buildRoundedGridCyclePath(cycle, radius))
.filter(Boolean)
.join(' ');
}
export function buildMergedGroupClipPath(
group: PuzzleMergedGroupShape,
radius = MERGED_GROUP_OUTLINE_CORNER_RADIUS,
) {
return buildMergedGroupBoundaryCycles(group)
.map((cycle) =>
buildRoundedGridCyclePath(cycle, radius, (point) => ({
x: point.x / group.colSpan,
y: point.y / group.rowSpan,
})),
)
.filter(Boolean)
.join(' ');
}
export function sanitizeSvgId(value: string) {
return value.replace(/[^a-zA-Z0-9_-]/g, '-');
}
export function resolveDraggedPieceCellLayer(
pieceId: string | null | undefined,
draggingPieceId: string | null,
isMerged: boolean,
) {
if (!pieceId || isMerged || pieceId !== draggingPieceId) {
return undefined;
}
return 80;
}
export function resolveDraggedPieceLayer(
pieceId: string | null | undefined,
draggingPieceId: string | null,
isMerged: boolean,
) {
if (!pieceId || isMerged || pieceId !== draggingPieceId) {
return undefined;
}
return 81;
}
export function resolveDraggedMergedGroupLayer(
groupId: string,
draggingGroupId: string | null,
) {
return groupId === draggingGroupId ? 90 : undefined;
}

View File

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

View File

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

View File

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

View File

@@ -14,14 +14,23 @@ import { createPortal } from 'react-dom';
import { AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS } from '../../data/affinityLevels';
import {
buildCustomWorldPlayableCharacters,
buildCustomWorldRuntimeCharacters,
createCharacterSkillCooldowns,
getCharacterMaxHp,
getCharacterMaxMana,
ROLE_TEMPLATE_CHARACTERS,
setRuntimeCharacterOverrides,
} from '../../data/characterPresets';
import { setRuntimeCustomWorldProfile } from '../../data/customWorldRuntime';
import { normalizeCustomWorldLandmarks } from '../../data/customWorldSceneGraph';
import {
getAllCustomWorldSceneImages,
resolveCustomWorldCampSceneImage,
resolveCustomWorldLandmarkImage,
} from '../../data/customWorldVisuals';
import { buildInitialEquipmentLoadout } from '../../data/equipmentEffects';
import { createInitialPlayerProgressionState } from '../../data/playerProgression';
import { createInitialGameRuntimeStats } from '../../data/runtimeStats';
import { RESOLVED_ENTITY_X_METERS } from '../../data/sceneEncounterPreviews';
import { buildEncounterFromSceneNpc } from '../../data/scenePresets';
import { EDITOR_ITEM_CATALOG_API_PATH } from '../../editor/shared/editorApiClient';
@@ -75,6 +84,8 @@ import {
type SceneChapterBlueprint,
type SceneNpc,
type ScenePresetInfo,
type StoryMoment,
type StoryOption,
WorldType,
} from '../../types';
import {
@@ -1946,6 +1957,93 @@ function buildSceneActPreviewScenePreset(params: {
};
}
const SCENE_ACT_PREVIEW_SESSION_ID = 'runtime-scene-act-preview';
function buildSceneActPreviewNpcOption(params: {
functionId: string;
actionText: string;
npcId: string;
action: 'chat' | 'fight' | 'leave';
}): StoryOption {
return {
functionId: params.functionId,
actionText: params.actionText,
text: params.actionText,
detailText: '',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
interaction: {
kind: 'npc',
npcId: params.npcId,
action: params.action,
},
};
}
function buildSceneActPreviewOpeningStory(params: {
sceneName: string;
encounter: NonNullable<ReturnType<typeof buildEncounterFromSceneNpc>>;
}): StoryMoment {
const npcId = params.encounter.id ?? params.encounter.npcName;
const openingText = `${params.encounter.npcName}已经在${params.sceneName || '当前场景'}等你。`;
return {
text: openingText,
displayMode: 'dialogue',
dialogue: [
{
speaker: 'npc',
speakerName: params.encounter.npcName,
text: openingText,
},
],
streaming: false,
npcChatState: {
npcId,
npcName: params.encounter.npcName,
turnCount: 0,
customInputPlaceholder: '输入你想对 TA 说的话',
openingSource: 'npc_initiated',
sceneActId: null,
turnLimit: null,
remainingTurns: null,
limitReason: null,
forceExitAfterTurn: false,
terminationMode: null,
terminationReason: null,
isHostileChat: false,
pendingQuestOffer: null,
combatContext: null,
},
options: [
buildSceneActPreviewNpcOption({
functionId: 'npc_chat',
actionText: '先听他说完',
npcId,
action: 'chat',
}),
buildSceneActPreviewNpcOption({
functionId: 'npc_fight',
actionText: '与他对战',
npcId,
action: 'fight',
}),
buildSceneActPreviewNpcOption({
functionId: 'npc_leave',
actionText: '暂时离开',
npcId,
action: 'leave',
}),
],
};
}
function SceneActPreviewRuntime({
profile,
landmark,
@@ -2045,8 +2143,10 @@ function SceneActPreviewRuntime({
useNpcInteractionFlow(gameState);
const isPreviewReady =
gameState.currentScene === 'Story' &&
Boolean(gameState.playerCharacter) &&
gameState.currentScenePreset?.id === landmark.id;
gameState.runtimeSessionId === SCENE_ACT_PREVIEW_SESSION_ID &&
gameState.playerCharacter?.id === previewCharacter?.id &&
gameState.currentScenePreset?.id === previewScenePreset?.id &&
Boolean(storyFlow.currentStory);
useEffect(() => {
if (
@@ -2064,28 +2164,64 @@ function SceneActPreviewRuntime({
storyFlow.resetStoryState();
setBottomTab('adventure');
setIsMapOpen(false);
handleCustomWorldSelect(profile);
handleCharacterSelect(previewCharacter);
// 中文注释:幕预览只需要同步静态资料层,不能调用正式选世界入口;
// 后者会排队写入“已选世界但未选角”的中间态,把本地预览 GameState 覆盖回加载中。
setRuntimeCustomWorldProfile(profile);
setRuntimeCharacterOverrides(buildCustomWorldRuntimeCharacters(profile));
const previewCharacterMaxHp = getCharacterMaxHp(
previewCharacter,
WorldType.CUSTOM,
profile,
);
const previewCharacterMaxMana = getCharacterMaxMana(previewCharacter);
const previewEquipment = buildInitialEquipmentLoadout(
previewCharacter,
profile,
);
setGameState((current) => ({
...current,
worldType: WorldType.CUSTOM,
customWorldProfile: profile,
playerCharacter: previewCharacter,
runtimeSessionId: SCENE_ACT_PREVIEW_SESSION_ID,
runtimeActionVersion: 1,
// 中文注释:幕预览也统一复用正式 play 运行链,
// 只通过禁持久化控制“不写正式存档”。
runtimeMode: 'play',
runtimePersistenceDisabled: true,
runtimeStats: createInitialGameRuntimeStats({ isActiveRun: true }),
playerProgression: createInitialPlayerProgressionState(),
currentScene: 'Story',
currentScenePreset: previewScenePreset,
currentEncounter: previewEncounter,
npcInteractionActive: false,
sceneHostileNpcs: [],
inBattle: false,
playerHp: previewCharacterMaxHp,
playerMaxHp: previewCharacterMaxHp,
playerMana: previewCharacterMaxMana,
playerMaxMana: previewCharacterMaxMana,
playerSkillCooldowns: createCharacterSkillCooldowns(previewCharacter),
playerCurrency: 0,
playerInventory: [],
playerEquipment: previewEquipment,
npcStates: {},
quests: [],
roster: [],
companions: [],
storyHistory: [],
chapterState: null,
campaignState: null,
activeScenarioPackId: profile.scenarioPackId ?? null,
activeCampaignPackId: profile.campaignPackId ?? null,
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
characterChats: {},
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
@@ -2102,11 +2238,14 @@ function SceneActPreviewRuntime({
currentSceneActState: previewActRuntimeState,
},
}));
storyFlow.hydrateStoryState(
buildSceneActPreviewOpeningStory({
sceneName: previewScenePreset.name,
encounter: previewEncounter,
}),
);
}, [
act,
handleCharacterSelect,
handleCustomWorldSelect,
landmark.id,
previewActRuntimeState,
previewCharacter,
previewEncounter,

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,108 @@
import { expect, test } from 'vitest';
import {
buildPuzzleWorkCoverSlides,
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([
'超长机关',
'星桥',
]);
});
test('buildPuzzleWorkCoverSlides prefers each level formal image', () => {
const slides = buildPuzzleWorkCoverSlides({
workId: 'work-1',
profileId: 'profile-1',
ownerUserId: 'user-1',
authorDisplayName: '玩家',
levelName: '第一关',
summary: '拼图摘要',
themeTags: ['拼图'],
coverImageSrc: '/cover.png',
publicationStatus: 'published',
updatedAt: '2026-04-25T00:00:00.000Z',
publishedAt: '2026-04-25T00:00:00.000Z',
publishReady: true,
levels: [
{
levelId: 'level-1',
levelName: '石桥',
pictureDescription: '石桥画面',
selectedCandidateId: 'candidate-2',
coverImageSrc: '/level-1-cover.png',
coverAssetId: null,
generationStatus: 'ready',
candidates: [
{
candidateId: 'candidate-1',
imageSrc: '/level-1-a.png',
assetId: 'asset-1',
prompt: '',
sourceType: 'generated',
selected: false,
},
{
candidateId: 'candidate-2',
imageSrc: '/level-1-b.png',
assetId: 'asset-2',
prompt: '',
sourceType: 'generated',
selected: false,
},
],
},
{
levelId: 'level-2',
levelName: '星港',
pictureDescription: '星港画面',
selectedCandidateId: null,
coverImageSrc: '/level-2-cover.png',
coverAssetId: null,
generationStatus: 'ready',
candidates: [],
},
],
});
expect(slides).toEqual([
{
id: 'level-1',
imageSrc: '/level-1-b.png',
label: '石桥',
},
{
id: 'level-2',
imageSrc: '/level-2-cover.png',
label: '星港',
},
]);
});

View File

@@ -1,21 +1,28 @@
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleDraftLevel } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type {
CustomWorldGalleryCard,
CustomWorldLibraryEntry,
} from '../../../packages/shared/src/contracts/runtime';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals';
import {
buildBigFishPublicWorkCode,
buildMatch3DPublicWorkCode,
buildPuzzlePublicWorkCode,
} 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>
| PlatformBigFishGalleryCard
| PlatformMatch3DGalleryCard
| PlatformPuzzleGalleryCard;
export type PlatformPuzzleGalleryCard = {
@@ -29,12 +36,23 @@ export type PlatformPuzzleGalleryCard = {
subtitle: string;
summaryText: string;
coverImageSrc: string | null;
coverSlides?: PlatformPuzzleCoverSlide[];
themeTags: string[];
playCount?: number;
remixCount?: number;
likeCount?: number;
recentPlayCount7d?: number;
visibility: 'published';
publishedAt: string | null;
updatedAt: string;
};
export type PlatformPuzzleCoverSlide = {
id: string;
imageSrc: string;
label: string;
};
export type PlatformBigFishGalleryCard = {
sourceType: 'big-fish';
workId: string;
@@ -47,6 +65,31 @@ export type PlatformBigFishGalleryCard = {
summaryText: string;
coverImageSrc: string | null;
themeTags: string[];
playCount?: number;
remixCount?: number;
likeCount?: number;
recentPlayCount7d?: number;
visibility: 'published';
publishedAt: string | null;
updatedAt: string;
};
export type PlatformMatch3DGalleryCard = {
sourceType: 'match3d';
workId: string;
profileId: string;
publicWorkCode: string;
ownerUserId: string;
authorDisplayName: string;
worldName: string;
subtitle: string;
summaryText: string;
coverImageSrc: string | null;
themeTags: string[];
playCount?: number;
remixCount?: number;
likeCount?: number;
recentPlayCount7d?: number;
visibility: 'published';
publishedAt: string | null;
updatedAt: string;
@@ -55,6 +98,7 @@ export type PlatformBigFishGalleryCard = {
export type PlatformPublicGalleryCard =
| CustomWorldGalleryCard
| PlatformBigFishGalleryCard
| PlatformMatch3DGalleryCard
| PlatformPuzzleGalleryCard;
export function isLibraryWorldEntry(
@@ -75,6 +119,12 @@ export function isBigFishGalleryEntry(
return 'sourceType' in entry && entry.sourceType === 'big-fish';
}
export function isMatch3DGalleryEntry(
entry: PlatformWorldCardLike,
): entry is PlatformMatch3DGalleryCard {
return 'sourceType' in entry && entry.sourceType === 'match3d';
}
export function mapPuzzleWorkToPlatformGalleryCard(
work: PuzzleWorkSummary,
): PlatformPuzzleGalleryCard {
@@ -85,17 +135,47 @@ export function mapPuzzleWorkToPlatformGalleryCard(
publicWorkCode: buildPuzzlePublicWorkCode(work.profileId),
ownerUserId: work.ownerUserId,
authorDisplayName: work.authorDisplayName,
worldName: work.levelName,
worldName: work.workTitle || work.levelName,
subtitle: '拼图关卡',
summaryText: work.summary,
summaryText: work.workDescription || work.summary,
coverImageSrc: work.coverImageSrc,
coverSlides: buildPuzzleWorkCoverSlides(work),
themeTags: work.themeTags,
playCount: work.playCount ?? 0,
remixCount: work.remixCount ?? 0,
likeCount: work.likeCount ?? 0,
recentPlayCount7d: work.recentPlayCount7d ?? 0,
visibility: 'published',
publishedAt: work.publishedAt,
updatedAt: work.updatedAt,
};
}
export function mapMatch3DWorkToPlatformGalleryCard(
work: Match3DWorkSummary,
): PlatformMatch3DGalleryCard {
return {
sourceType: 'match3d',
workId: work.workId,
profileId: work.profileId,
publicWorkCode: buildMatch3DPublicWorkCode(work.profileId),
ownerUserId: work.ownerUserId,
authorDisplayName: '玩家',
worldName: work.gameName,
subtitle: '经典消除玩法',
summaryText: work.summary,
coverImageSrc: work.coverImageSrc ?? null,
themeTags: work.tags.length > 0 ? work.tags : [work.themeText, '抓大鹅'],
playCount: work.playCount ?? 0,
remixCount: 0,
likeCount: 0,
recentPlayCount7d: 0,
visibility: 'published',
publishedAt: work.publishedAt ?? null,
updatedAt: work.updatedAt,
};
}
export function mapBigFishWorkToPlatformGalleryCard(
work: BigFishWorkSummary,
): PlatformBigFishGalleryCard {
@@ -105,18 +185,34 @@ export function mapBigFishWorkToPlatformGalleryCard(
profileId: work.sourceSessionId,
publicWorkCode: buildBigFishPublicWorkCode(work.sourceSessionId),
ownerUserId: work.ownerUserId,
authorDisplayName: '大鱼创作者',
authorDisplayName: work.authorDisplayName,
worldName: work.title,
subtitle: work.subtitle || '大鱼吃小鱼',
summaryText: work.summary,
coverImageSrc: work.coverImageSrc,
themeTags: ['大鱼', `${work.levelCount}`],
playCount: work.playCount ?? 0,
remixCount: work.remixCount ?? 0,
likeCount: work.likeCount ?? 0,
recentPlayCount7d: work.recentPlayCount7d ?? 0,
visibility: 'published',
publishedAt: work.updatedAt,
publishedAt: work.publishedAt ?? work.updatedAt,
updatedAt: work.updatedAt,
};
}
export function resolvePlatformWorldStats(entry: PlatformWorldCardLike) {
return {
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,
};
}
export function resolvePlatformWorldCoverImage(entry: PlatformWorldCardLike) {
if (entry.coverImageSrc) {
return entry.coverImageSrc;
@@ -129,6 +225,89 @@ export function resolvePlatformWorldCoverImage(entry: PlatformWorldCardLike) {
return '';
}
export function resolvePlatformWorldCoverSlides(
entry: PlatformWorldCardLike,
): PlatformPuzzleCoverSlide[] {
const fallbackCoverImage = resolvePlatformWorldCoverImage(entry).trim();
const puzzleCoverSlides = isPuzzleGalleryEntry(entry)
? (entry.coverSlides ?? [])
: [];
const normalizedSlides = puzzleCoverSlides
.map((slide, index) => ({
id: slide.id.trim() || `cover-${index + 1}`,
imageSrc: slide.imageSrc.trim(),
label: slide.label.trim() || entry.worldName,
}))
.filter((slide) => Boolean(slide.imageSrc));
if (normalizedSlides.length > 0) {
return normalizedSlides;
}
return fallbackCoverImage
? [
{
id: 'cover',
imageSrc: fallbackCoverImage,
label: entry.worldName,
},
]
: [];
}
export function resolvePuzzleLevelFormalImageSrc(level: PuzzleDraftLevel) {
const selectedCandidate =
level.candidates.find(
(candidate) =>
candidate.selected ||
(level.selectedCandidateId
? candidate.candidateId === level.selectedCandidateId
: false),
) ??
level.candidates[level.candidates.length - 1] ??
null;
return (
selectedCandidate?.imageSrc?.trim() || level.coverImageSrc?.trim() || ''
);
}
export function buildPuzzleWorkCoverSlides(
work: PuzzleWorkSummary,
): PlatformPuzzleCoverSlide[] {
const slides: PlatformPuzzleCoverSlide[] = [];
const usedImageSrcSet = new Set<string>();
work.levels?.forEach((level, index) => {
const imageSrc = resolvePuzzleLevelFormalImageSrc(level);
if (!imageSrc || usedImageSrcSet.has(imageSrc)) {
return;
}
usedImageSrcSet.add(imageSrc);
slides.push({
id: level.levelId?.trim() || `puzzle-level-${index + 1}`,
imageSrc,
label: level.levelName?.trim() || `${index + 1}`,
});
});
if (slides.length > 0) {
return slides;
}
const fallbackImageSrc = work.coverImageSrc?.trim() ?? '';
return fallbackImageSrc
? [
{
id: 'cover',
imageSrc: fallbackImageSrc,
label: work.levelName,
},
]
: [];
}
export function resolvePlatformWorldLeadPortrait(entry: PlatformWorldCardLike) {
if (!isLibraryWorldEntry(entry)) {
return '';
@@ -137,6 +316,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) : ['大鱼'];
@@ -146,6 +363,10 @@ export function buildPlatformWorldTags(entry: PlatformWorldCardLike) {
return entry.themeTags.length > 0 ? entry.themeTags.slice(0, 3) : ['拼图'];
}
if (isMatch3DGalleryEntry(entry)) {
return entry.themeTags.length > 0 ? entry.themeTags.slice(0, 3) : ['抓大鹅'];
}
if (!isLibraryWorldEntry(entry)) {
return [
describePlatformThemeLabel(entry.themeMode),
@@ -163,20 +384,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(
@@ -190,6 +441,10 @@ export function resolvePlatformPublicWorkCode(
return entry.publicWorkCode;
}
if (isMatch3DGalleryEntry(entry)) {
return entry.publicWorkCode;
}
return entry.publicWorkCode;
}

View File

@@ -280,6 +280,7 @@ describe('RPG Agent 草稿恢复', () => {
themeMode: 'tide',
playableNpcCount: 0,
landmarkCount: 0,
likeCount: 0,
},
entries: [],
});

View File

@@ -136,6 +136,25 @@ export function useRpgEntryBootstrap(
return nextEntries;
}, [canReadProtectedData, user]);
const refreshSaveArchives = useCallback(async () => {
if (!user || !canReadProtectedData) {
setSaveEntries([]);
setSaveError(null);
return [];
}
setSaveError(null);
try {
const nextEntries = await listRpgProfileSaveArchives();
setSaveEntries(nextEntries);
return nextEntries;
} catch (error) {
setSaveError(resolveRpgEntryErrorMessage(error, '读取存档列表失败。'));
return [];
}
}, [canReadProtectedData, user]);
const appendBrowseHistoryEntry = useCallback(
async (entry: PlatformBrowseHistoryWriteEntry) => {
setHistoryError(null);
@@ -251,6 +270,8 @@ export function useRpgEntryBootstrap(
if (galleryEntriesResult.status === 'fulfilled') {
setPublishedGalleryEntries(galleryEntriesResult.value);
} else {
// 中文注释:公开广场只影响首页展示,失败时降级为空列表;
// 私有作品库和创作作品列表的受保护失败才需要阻塞提示。
setPublishedGalleryEntries([]);
}
@@ -258,17 +279,14 @@ export function useRpgEntryBootstrap(
(canReadProtectedData &&
libraryEntriesResult.status === 'rejected') ||
(canReadProtectedData &&
workEntriesResult.status === 'rejected') ||
galleryEntriesResult.status === 'rejected'
workEntriesResult.status === 'rejected')
) {
const platformFailure =
libraryEntriesResult.status === 'rejected'
? libraryEntriesResult.reason
: workEntriesResult.status === 'rejected'
? workEntriesResult.reason
: galleryEntriesResult.status === 'rejected'
? galleryEntriesResult.reason
: null;
: null;
setPlatformError(
resolveRpgEntryErrorMessage(platformFailure, '读取平台数据失败。'),
);
@@ -371,6 +389,7 @@ export function useRpgEntryBootstrap(
refreshCustomWorldWorks,
refreshPublishedGallery,
refreshSavedCustomWorldLibrary,
refreshSaveArchives,
appendBrowseHistoryEntry,
handleResumeSaveEntry,
};

View File

@@ -14,6 +14,7 @@ import {
import { ApiClientError } from '../../services/apiClient';
import {
deleteRpgEntryWorldProfile,
getRpgEntryWorldLibraryDetail,
getRpgEntryWorldGalleryDetail,
listRpgEntryWorldLibrary,
publishRpgEntryWorldProfile,
@@ -138,7 +139,7 @@ export function useRpgEntryLibraryDetail(
}, [savedCustomWorldEntries, selectedDetailEntry, setSelectedDetailEntry]);
const openLibraryDetail = useCallback(
(entry: CustomWorldLibraryEntry<CustomWorldProfile>) => {
async (entry: CustomWorldLibraryEntry<CustomWorldProfile>) => {
if (entry.visibility === 'published') {
void appendBrowseHistoryEntry({
ownerUserId: entry.ownerUserId,
@@ -157,8 +158,50 @@ 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(
async (entry: CustomWorldGalleryCard) => {
const detailEntry = await getRpgEntryWorldGalleryDetail(
entry.ownerUserId,
entry.profileId,
);
void appendBrowseHistoryEntry({
ownerUserId: detailEntry.ownerUserId,
profileId: detailEntry.profileId,
worldName: detailEntry.worldName,
subtitle: detailEntry.subtitle,
summaryText: detailEntry.summaryText,
coverImageSrc: detailEntry.coverImageSrc,
themeMode: detailEntry.themeMode,
authorDisplayName: detailEntry.authorDisplayName,
});
return detailEntry;
},
[appendBrowseHistoryEntry],
);
const openGalleryDetail = useCallback(
@@ -168,26 +211,13 @@ export function useRpgEntryLibraryDetail(
setDetailError(null);
try {
const detailEntry = await getRpgEntryWorldGalleryDetail(
entry.ownerUserId,
entry.profileId,
);
const detailEntry = await loadGalleryDetailEntry(entry);
setSelectedDetailEntry(detailEntry);
if (detailEntry.publicWorkCode?.trim()) {
pushAppHistoryPath(
buildPublicWorkDetailPath(detailEntry.publicWorkCode),
);
}
void appendBrowseHistoryEntry({
ownerUserId: detailEntry.ownerUserId,
profileId: detailEntry.profileId,
worldName: detailEntry.worldName,
subtitle: detailEntry.subtitle,
summaryText: detailEntry.summaryText,
coverImageSrc: detailEntry.coverImageSrc,
themeMode: detailEntry.themeMode,
authorDisplayName: detailEntry.authorDisplayName,
});
} catch (error) {
setSelectedDetailEntry(null);
setDetailError(
@@ -197,25 +227,39 @@ export function useRpgEntryLibraryDetail(
setIsDetailLoading(false);
}
},
[appendBrowseHistoryEntry, setSelectedDetailEntry, setSelectionStage],
[
loadGalleryDetailEntry,
setSelectedDetailEntry,
setSelectionStage,
],
);
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,
@@ -227,6 +271,7 @@ export function useRpgEntryLibraryDetail(
setGeneratedCustomWorldProfile,
setSelectedDetailEntry,
setSelectionStage,
userId,
],
);
@@ -334,7 +379,7 @@ export function useRpgEntryLibraryDetail(
}
if (matchedEntry) {
openLibraryDetail(matchedEntry);
void openLibraryDetail(matchedEntry);
return;
}
@@ -489,6 +534,7 @@ export function useRpgEntryLibraryDetail(
isSelectedWorldOwned,
openLibraryDetail,
openGalleryDetail,
loadGalleryDetailEntry,
openSavedCustomWorldEditor,
handleOpenCreationWork,
handlePublishSelectedWorld,

View File

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

View File

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

View File

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