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:
@@ -1,4 +1,3 @@
|
||||
import { X } from 'lucide-react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
@@ -88,6 +87,7 @@ import {
|
||||
InventoryItemGrid,
|
||||
} from './InventoryItemViews';
|
||||
import { MedievalNpcAnimator } from './MedievalNpcAnimator';
|
||||
import { PixelCloseButton } from './PixelCloseButton';
|
||||
import { ResolvedAssetImage } from './ResolvedAssetImage';
|
||||
import { SkillEffectPreview } from './SkillEffectPreview';
|
||||
|
||||
@@ -957,8 +957,8 @@ export function AdventureEntityModal({
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||||
<div>
|
||||
<div className="relative flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||||
<div className="min-w-0 pr-10">
|
||||
<div className="text-[10px] uppercase tracking-[0.24em] text-zinc-500">
|
||||
详情
|
||||
</div>
|
||||
@@ -975,13 +975,7 @@ export function AdventureEntityModal({
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
<PixelCloseButton onClick={onClose} label="关闭冒险详情" />
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4 sm:p-5">
|
||||
@@ -1319,13 +1313,10 @@ export function AdventureEntityModal({
|
||||
{detailCharacter.name}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
<PixelCloseButton
|
||||
onClick={() => setSelectedContributionLabel(null)}
|
||||
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
label="关闭标签效果"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto p-4 sm:p-5">
|
||||
@@ -1431,13 +1422,10 @@ export function AdventureEntityModal({
|
||||
{selectedSkillOwnerName}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
<PixelCloseButton
|
||||
onClick={() => setSelectedSkillId(null)}
|
||||
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
label="关闭技能详情"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 overflow-y-auto p-4 sm:p-5">
|
||||
|
||||
@@ -2,8 +2,8 @@ import { AnimatePresence, motion } from 'motion/react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import type { CharacterChatModalState } from '../hooks/rpg-runtime-story';
|
||||
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import { PixelIcon } from './PixelIcon';
|
||||
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import { PixelCloseButton } from './PixelCloseButton';
|
||||
|
||||
interface CharacterChatModalProps {
|
||||
modal: CharacterChatModalState | null;
|
||||
@@ -56,13 +56,11 @@ export function CharacterChatModal({
|
||||
{modal.target.character.title} / {modal.target.roleLabel}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
<PixelCloseButton
|
||||
onClick={onClose}
|
||||
className="rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
label="关闭角色聊天"
|
||||
placement="inline"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 flex-1 gap-4 overflow-y-auto p-4 sm:grid-cols-[minmax(0,0.88fr)_minmax(0,1.12fr)] sm:overflow-hidden sm:p-5">
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
type WorldType,
|
||||
} from '../types';
|
||||
import {
|
||||
CHROME_ICONS,
|
||||
getNineSliceStyle,
|
||||
type NineSliceTexture,
|
||||
UI_CHROME,
|
||||
@@ -38,7 +37,7 @@ import {
|
||||
CharacterSkillsList,
|
||||
} from './CharacterInfoShared';
|
||||
import { MedievalNpcAnimator } from './MedievalNpcAnimator';
|
||||
import { PixelIcon } from './PixelIcon';
|
||||
import { PixelCloseButton } from './PixelCloseButton';
|
||||
|
||||
interface CharacterDetailModalProps {
|
||||
character: Character | null;
|
||||
@@ -194,14 +193,7 @@ export function CharacterDetailModal({
|
||||
{subtitle}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
|
||||
aria-label="关闭角色详情"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
<PixelCloseButton onClick={onClose} label="关闭角色详情" />
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 flex-1 gap-4 overflow-y-auto p-5 lg:grid-cols-[minmax(0,0.82fr)_minmax(0,1.18fr)] lg:overflow-hidden">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { normalizePlayerProgressionState } from '../data/playerProgression';
|
||||
import {
|
||||
resolveAttributeSchema,
|
||||
resolveCharacterAttributeProfile,
|
||||
@@ -27,6 +26,7 @@ import {
|
||||
getEquipmentSlotLabel,
|
||||
} from '../data/equipmentEffects';
|
||||
import { buildMedievalNpcVisualFromCustomWorldVisual } from '../data/medievalNpcVisuals';
|
||||
import { normalizePlayerProgressionState } from '../data/playerProgression';
|
||||
import type { CharacterChatTarget } from '../hooks/rpg-runtime-story';
|
||||
import { getResourceLabelsForWorld } from '../services/customWorldPresentation';
|
||||
import {
|
||||
@@ -38,12 +38,10 @@ import {
|
||||
CustomWorldProfile,
|
||||
EquipmentLoadout,
|
||||
GameState,
|
||||
QuestLogEntry,
|
||||
TimedBuildBuff,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import {
|
||||
CHROME_ICONS,
|
||||
getEquipmentSlotIcon,
|
||||
getNineSliceStyle,
|
||||
UI_CHROME,
|
||||
@@ -66,6 +64,7 @@ import {
|
||||
} from './CharacterInfoShared';
|
||||
import type { GameCanvasEntitySelection } from './GameCanvas';
|
||||
import { MedievalNpcAnimator } from './MedievalNpcAnimator';
|
||||
import { PixelCloseButton } from './PixelCloseButton';
|
||||
import { PixelIcon } from './PixelIcon';
|
||||
import { ResolvedAssetImage } from './ResolvedAssetImage';
|
||||
|
||||
@@ -82,7 +81,6 @@ interface CharacterPanelProps {
|
||||
activeBuildBuffs?: TimedBuildBuff[];
|
||||
companionRenderStates: CompanionRenderState[];
|
||||
npcStates?: GameState['npcStates'];
|
||||
quests: QuestLogEntry[];
|
||||
onOpenCamp?: () => void;
|
||||
onOpenCharacterChat?: (target: CharacterChatTarget) => void;
|
||||
chatSummaries?: Record<string, string>;
|
||||
@@ -155,7 +153,6 @@ export function CharacterPanel({
|
||||
activeBuildBuffs = [],
|
||||
companionRenderStates,
|
||||
npcStates = {},
|
||||
quests,
|
||||
onInspectMember,
|
||||
companionArcStates = [],
|
||||
companionResolutions = [],
|
||||
@@ -215,11 +212,6 @@ export function CharacterPanel({
|
||||
[partyMembers, selectedMemberId],
|
||||
);
|
||||
|
||||
const activeQuests = useMemo(
|
||||
() => quests.filter((quest) => quest.status !== 'turned_in'),
|
||||
[quests],
|
||||
);
|
||||
|
||||
const buildBreakdownByMemberId = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
@@ -374,29 +366,6 @@ export function CharacterPanel({
|
||||
paddingY: 12,
|
||||
})}
|
||||
>
|
||||
{activeQuests.length > 0 && (
|
||||
<div className="mb-3 rounded-xl border border-sky-400/15 bg-sky-500/8 px-3 py-3">
|
||||
<div className="mb-2 text-xs font-bold text-sky-100">
|
||||
褰撳墠濮旀墭
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{activeQuests.map((quest) => (
|
||||
<div
|
||||
key={quest.id}
|
||||
className="rounded-lg border border-white/6 bg-black/18 px-3 py-2 text-sm text-zinc-200"
|
||||
>
|
||||
<div className="font-semibold text-white">
|
||||
{quest.title}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">
|
||||
{quest.summary}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-3 text-xs font-bold text-white">队伍成员</div>
|
||||
<div className="grid max-h-[calc(100vh-14rem)] grid-cols-1 gap-3 overflow-y-auto pr-1 scrollbar-hide sm:max-h-[calc(100vh-18rem)] md:grid-cols-2">
|
||||
{partyMembers.map((member) => (
|
||||
@@ -497,13 +466,10 @@ export function CharacterPanel({
|
||||
{selectedMember.character.name}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
<PixelCloseButton
|
||||
onClick={() => setSelectedContributionLabel(null)}
|
||||
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
label="关闭标签效果"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto p-4 sm:p-5">
|
||||
@@ -619,13 +585,10 @@ export function CharacterPanel({
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
<PixelCloseButton
|
||||
onClick={() => setSelectedMemberId(null)}
|
||||
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
label="关闭角色详情"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 flex-1 gap-4 overflow-y-auto p-4 sm:p-5 lg:grid-cols-[minmax(0,0.82fr)_minmax(0,1.18fr)] lg:overflow-hidden">
|
||||
|
||||
@@ -4,8 +4,8 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { getCharacterById } from '../data/characterPresets';
|
||||
import { MAX_COMPANIONS } from '../data/npcInteractions';
|
||||
import { Character, CompanionState } from '../types';
|
||||
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import { PixelIcon } from './PixelIcon';
|
||||
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import { PixelCloseButton } from './PixelCloseButton';
|
||||
import { ResolvedAssetImage } from './ResolvedAssetImage';
|
||||
|
||||
interface CompanionCampModalProps {
|
||||
@@ -145,13 +145,7 @@ export function CompanionCampModal({
|
||||
{playerCharacter ? `${playerCharacter.name} / 出战 ${companions.length}/${MAX_COMPANIONS}` : '队伍调度'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
<PixelCloseButton onClick={onClose} label="关闭营地编组" />
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 flex-1 gap-4 overflow-y-auto p-5 lg:grid-cols-[1.05fr_0.95fr] lg:overflow-hidden">
|
||||
|
||||
@@ -1099,7 +1099,7 @@ export function CustomWorldEntityCatalog({
|
||||
<div className="flex flex-wrap items-center gap-2 px-1">
|
||||
{lockedCharacterNames.has(role.name.trim()) ? (
|
||||
<span className="platform-pill platform-pill--warm px-2.5 py-1 text-[10px]">
|
||||
创作者锁定
|
||||
百梦主锁定
|
||||
</span>
|
||||
) : null}
|
||||
<span className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">
|
||||
|
||||
@@ -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: '结束预览' }));
|
||||
|
||||
|
||||
@@ -5,11 +5,11 @@ import { resolveInventoryItemUseEffect } from '../data/inventoryEffects';
|
||||
import { buildInventoryItemDescription } from '../data/itemPresentation';
|
||||
import type { Character, InventoryItem, WorldType } from '../types';
|
||||
import {
|
||||
CHROME_ICONS,
|
||||
getInventoryItemVisualSrc,
|
||||
getNineSliceStyle,
|
||||
UI_CHROME,
|
||||
} from '../uiAssets';
|
||||
import { PixelCloseButton } from './PixelCloseButton';
|
||||
import { PixelIcon } from './PixelIcon';
|
||||
|
||||
function getInventoryRarityTheme(rarity: InventoryItem['rarity']) {
|
||||
@@ -185,13 +185,7 @@ export function InventoryItemDetailModal({
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="relative flex min-h-0 flex-1 flex-col gap-4 p-4 sm:gap-5 sm:p-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute right-4 top-4 z-10 rounded-full border border-white/10 bg-black/25 p-1.5 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-5"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
<PixelCloseButton onClick={onClose} label="关闭物品详情" className="top-4 sm:top-5" />
|
||||
|
||||
<div
|
||||
className={`relative overflow-hidden rounded-[1.5rem] border px-4 py-5 sm:px-6 sm:py-6 ${rarityTheme.frameClass}`}
|
||||
|
||||
@@ -38,7 +38,6 @@ interface InventoryPanelProps {
|
||||
onCraftRecipe: (recipeId: string) => Promise<boolean>;
|
||||
onDismantleItem: (itemId: string) => Promise<boolean>;
|
||||
onReforgeItem: (itemId: string) => Promise<boolean>;
|
||||
continueGameDigest?: string | null;
|
||||
narrativeCodex?: NarrativeCodexSection[];
|
||||
narrativeQaReport?: NarrativeQaReport | null;
|
||||
}
|
||||
@@ -58,7 +57,6 @@ export function InventoryPanel({
|
||||
onCraftRecipe,
|
||||
onDismantleItem: _onDismantleItem,
|
||||
onReforgeItem: _onReforgeItem,
|
||||
continueGameDigest = null,
|
||||
narrativeCodex = [],
|
||||
narrativeQaReport = null,
|
||||
}: InventoryPanelProps) {
|
||||
@@ -92,14 +90,6 @@ export function InventoryPanel({
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex-1 overflow-y-auto scrollbar-hide">
|
||||
{continueGameDigest && (
|
||||
<div className="mb-4 rounded-2xl border border-white/10 bg-black/20 p-4 text-xs leading-relaxed text-zinc-300">
|
||||
<div className="mb-2 text-[10px] uppercase tracking-[0.22em] text-zinc-500">
|
||||
旅程回顾
|
||||
</div>
|
||||
{continueGameDigest}
|
||||
</div>
|
||||
)}
|
||||
<InventoryItemGrid
|
||||
items={inventoryItems}
|
||||
selectedItemId={selectedItem?.id ?? null}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getConnectedScenePresets } from '../data/scenePresets';
|
||||
import { useResolvedAssetReadUrl } from '../hooks/useResolvedAssetReadUrl';
|
||||
import { ScenePresetInfo, WorldType } from '../types';
|
||||
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import { PixelCloseButton } from './PixelCloseButton';
|
||||
import { PixelIcon } from './PixelIcon';
|
||||
|
||||
function buildSceneBackdropStyle(imageSrc?: string | null): CSSProperties {
|
||||
@@ -252,13 +253,7 @@ export function MapModal({
|
||||
<span>地图</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
<PixelCloseButton onClick={onClose} label="关闭地图" />
|
||||
</div>
|
||||
|
||||
<div className="relative grid min-h-0 flex-1 gap-4 overflow-y-auto p-4 sm:p-5 md:grid-cols-[minmax(15rem,18rem)_minmax(0,1fr)] md:overflow-hidden">
|
||||
@@ -385,13 +380,10 @@ export function MapModal({
|
||||
<div className="text-[10px] tracking-[0.22em] text-amber-200/80">场景切换</div>
|
||||
<div className="mt-1 truncate text-sm font-semibold text-white">{pendingScene.scene.name}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
<PixelCloseButton
|
||||
onClick={() => setPendingScene(null)}
|
||||
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
label="关闭场景切换"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4 sm:p-5">
|
||||
|
||||
@@ -25,7 +25,8 @@ import {
|
||||
RuntimeNpcGiftItemView,
|
||||
RuntimeNpcTradeItemView,
|
||||
} from '../types';
|
||||
import { CHROME_ICONS, getInventoryItemVisualSrc, getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import { getInventoryItemVisualSrc, getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import { PixelCloseButton } from './PixelCloseButton';
|
||||
import { PixelIcon } from './PixelIcon';
|
||||
|
||||
interface NpcModalsProps {
|
||||
@@ -232,13 +233,11 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
|
||||
{npcInteraction?.npcName ?? tradeModal.encounter.npcName} / 你当前{currencyName}:{npcInteraction?.playerCurrency ?? gameState.playerCurrency}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
<PixelCloseButton
|
||||
onClick={npcUi.closeTradeModal}
|
||||
className="rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
label="关闭交易"
|
||||
placement="inline"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3 sm:px-5 sm:py-4">
|
||||
@@ -385,13 +384,11 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
|
||||
{tradeDetail.source === 'buy' ? '对方物品' : '背包物品'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
<PixelCloseButton
|
||||
onClick={() => setTradeDetail(null)}
|
||||
className="rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
label="关闭物品详情"
|
||||
placement="inline"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-5">
|
||||
@@ -474,9 +471,11 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
|
||||
<div className="text-sm font-semibold text-white">赠送礼物</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">{npcUi.giftModal.encounter.npcName}</div>
|
||||
</div>
|
||||
<button type="button" onClick={npcUi.closeGiftModal} className="rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white">
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
<PixelCloseButton
|
||||
onClick={npcUi.closeGiftModal}
|
||||
label="关闭赠礼"
|
||||
placement="inline"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-5">
|
||||
@@ -550,9 +549,11 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
|
||||
<div className="text-sm font-semibold text-white">调整同行位置</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">队伍已满,请先选择一名当前同行角色离队,再邀请对方加入。</div>
|
||||
</div>
|
||||
<button type="button" onClick={npcUi.closeRecruitModal} className="rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white">
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
<PixelCloseButton
|
||||
onClick={npcUi.closeRecruitModal}
|
||||
label="关闭招募"
|
||||
placement="inline"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-5">
|
||||
|
||||
45
src/components/PixelCloseButton.test.tsx
Normal file
45
src/components/PixelCloseButton.test.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import { PixelCloseButton } from './PixelCloseButton';
|
||||
|
||||
test('pixel close button closes without bubbling to the overlay', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
const onOverlayClick = vi.fn();
|
||||
|
||||
render(
|
||||
<div onClick={onOverlayClick}>
|
||||
<PixelCloseButton onClick={onClose} label="关闭测试面板" />
|
||||
</div>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '关闭测试面板' }));
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(onOverlayClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('inline pixel close button keeps the same click boundary', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
const onHeaderClick = vi.fn();
|
||||
|
||||
render(
|
||||
<div onClick={onHeaderClick}>
|
||||
<PixelCloseButton
|
||||
onClick={onClose}
|
||||
label="关闭标题栏面板"
|
||||
placement="inline"
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '关闭标题栏面板' }));
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(onHeaderClick).not.toHaveBeenCalled();
|
||||
});
|
||||
45
src/components/PixelCloseButton.tsx
Normal file
45
src/components/PixelCloseButton.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { MouseEvent } from 'react';
|
||||
|
||||
import { CHROME_ICONS } from '../uiAssets';
|
||||
import { PixelIcon } from './PixelIcon';
|
||||
|
||||
type PixelCloseButtonProps = {
|
||||
onClick: () => void;
|
||||
label?: string;
|
||||
placement?: 'absolute' | 'inline';
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* RPG 像素风弹窗右上关闭按钮。
|
||||
* 统一拦截点击冒泡,避免历史手写 overlay / panel 的点击处理影响关闭行为。
|
||||
*/
|
||||
export function PixelCloseButton({
|
||||
onClick,
|
||||
label = '关闭面板',
|
||||
placement = 'absolute',
|
||||
className = '',
|
||||
}: PixelCloseButtonProps) {
|
||||
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onClick();
|
||||
};
|
||||
|
||||
const placementClassName =
|
||||
placement === 'absolute'
|
||||
? 'absolute right-4 top-3 sm:right-5 sm:top-4'
|
||||
: 'relative shrink-0';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onClick={handleClick}
|
||||
className={`${placementClassName} z-20 flex h-9 w-9 items-center justify-center rounded-full border border-white/10 bg-black/30 p-0 text-zinc-400 shadow-[0_8px_18px_rgba(0,0,0,0.28)] transition-colors hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-200/70 ${className}`.trim()}
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -16,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();
|
||||
|
||||
@@ -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)]">
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -17,6 +17,7 @@ type AuthUiContextValue = {
|
||||
requireAuth: (action: () => void) => void;
|
||||
openSettingsModal: (section?: PlatformSettingsSection) => void;
|
||||
openAccountModal: () => void;
|
||||
setCurrentUser: (user: AuthUser) => void;
|
||||
logout: () => Promise<void>;
|
||||
musicVolume: number;
|
||||
setMusicVolume: (value: number) => void;
|
||||
|
||||
@@ -62,7 +62,7 @@ export function BindPhoneScreen({
|
||||
<div className="platform-auth-card grid w-full max-w-4xl overflow-hidden rounded-[28px] md:grid-cols-[1.05fr_0.95fr]">
|
||||
<div className="border-b border-[var(--platform-subpanel-border)] bg-[linear-gradient(135deg,rgba(255,79,139,0.18),rgba(255,155,120,0.14))] px-6 py-8 md:border-b-0 md:border-r md:px-10 md:py-12">
|
||||
<div className="selection-hero-brand selection-hero-brand--left">
|
||||
<div className="selection-hero-brand__title">叙世</div>
|
||||
<div className="selection-hero-brand__title">百梦</div>
|
||||
<div className="selection-hero-brand__subtitle">视觉叙事 RPG</div>
|
||||
</div>
|
||||
<p className="mt-8 text-[11px] font-semibold tracking-[0.32em] text-[var(--platform-cool-text)]">
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
115
src/components/auth/RegistrationInviteModal.tsx
Normal file
115
src/components/auth/RegistrationInviteModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}`}
|
||||
>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -37,7 +37,7 @@ export function CustomWorldCreationStartCard({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="-mx-1 flex snap-x gap-2 overflow-x-auto px-1 pb-1 sm:mx-0 sm:grid sm:gap-3 sm:overflow-visible sm:px-0 sm:pb-0 sm:grid-cols-2 xl:grid-cols-5 xl:gap-2.5">
|
||||
<div className="-mx-1 flex snap-x gap-2 overflow-x-auto px-1 pb-1 scrollbar-hide sm:mx-0 sm:grid sm:gap-3 sm:overflow-visible sm:px-0 sm:pb-0 sm:grid-cols-2 xl:grid-cols-5 xl:gap-2.5">
|
||||
{visibleCreationTypes.map((item) => {
|
||||
const disabled = item.locked || busy;
|
||||
|
||||
@@ -49,22 +49,18 @@ export function CustomWorldCreationStartCard({
|
||||
onClick={() => {
|
||||
onCreateType(item.id);
|
||||
}}
|
||||
className={`platform-interactive-card relative min-h-[4rem] w-[11.25rem] shrink-0 snap-start overflow-hidden rounded-[1.15rem] border px-3 py-2.5 text-left transition sm:min-h-[8.5rem] sm:w-auto sm:rounded-[1.5rem] sm:px-4 sm:py-4 xl:min-h-[6.4rem] xl:px-3.5 xl:py-3 ${
|
||||
className={`platform-interactive-card relative flex min-h-[4rem] w-[11.25rem] shrink-0 snap-start flex-col overflow-hidden rounded-[1.15rem] border px-3 py-2.5 text-left transition sm:min-h-[8.5rem] sm:w-auto sm:rounded-[1.5rem] sm:px-4 sm:py-4 xl:min-h-[6.4rem] xl:px-3.5 xl:py-3 ${
|
||||
item.locked
|
||||
? 'cursor-not-allowed border-white/10 bg-white/8 text-zinc-300/70'
|
||||
: 'border-white/18 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.24),transparent_36%),linear-gradient(135deg,rgba(255,255,255,0.18),rgba(255,255,255,0.08))] text-white'
|
||||
} ${busy && !item.locked ? 'opacity-70' : ''}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 sm:items-start sm:gap-3">
|
||||
<span
|
||||
className={`platform-pill px-2.5 text-xs sm:px-3 sm:text-sm ${
|
||||
item.locked
|
||||
? 'platform-pill--neutral text-[var(--platform-text-soft)]'
|
||||
: 'platform-pill--neutral border-white/30 bg-white/18 text-white'
|
||||
}`}
|
||||
>
|
||||
{item.locked ? item.badge : busy ? '正在开启' : item.badge}
|
||||
</span>
|
||||
<div className="flex min-h-5 items-center justify-end gap-2 sm:items-start sm:gap-3">
|
||||
{item.locked ? (
|
||||
<span className="platform-pill platform-pill--neutral px-2.5 text-xs text-[var(--platform-text-soft)] sm:px-3 sm:text-sm">
|
||||
{item.badge}
|
||||
</span>
|
||||
) : null}
|
||||
{item.locked ? (
|
||||
<span className="text-base leading-none text-white/40">·</span>
|
||||
) : (
|
||||
@@ -72,15 +68,17 @@ export function CustomWorldCreationStartCard({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2.5 truncate text-base font-black leading-tight text-inherit sm:mt-7 sm:text-lg xl:mt-4 xl:text-base">
|
||||
{item.title}
|
||||
</div>
|
||||
<div
|
||||
className={`mt-1 truncate text-xs sm:mt-2 sm:text-sm xl:mt-1 xl:text-xs ${
|
||||
item.locked ? 'text-zinc-400' : 'text-zinc-200/82'
|
||||
}`}
|
||||
>
|
||||
{item.subtitle}
|
||||
<div className="mt-auto pt-1.5 sm:pt-4 xl:pt-2">
|
||||
<div className="truncate text-base font-black leading-tight text-inherit sm:text-lg xl:text-base">
|
||||
{item.title}
|
||||
</div>
|
||||
<div
|
||||
className={`mt-1 truncate text-xs sm:mt-2 sm:text-sm xl:mt-1 xl:text-xs ${
|
||||
item.locked ? 'text-zinc-400' : 'text-zinc-200/82'
|
||||
}`}
|
||||
>
|
||||
{item.subtitle}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -1,64 +1,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>
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -234,6 +234,55 @@ describe('GameCanvasEntityLayer', () => {
|
||||
expect(html).toContain('aria-label="好感度变化 +3"');
|
||||
});
|
||||
|
||||
it('keeps battle opponent visible when compat payload misses encounter context', () => {
|
||||
const hostileNpc = createHostileNpc({
|
||||
encounter: undefined,
|
||||
name: '断桥匪首',
|
||||
description: '刚进入战斗时的旧快照目标',
|
||||
});
|
||||
const html = renderToStaticMarkup(
|
||||
<GameCanvasEntityLayer
|
||||
companions={[]}
|
||||
sceneActAmbientEncounters={[]}
|
||||
currentScenePreset={null}
|
||||
sceneTransitionToken={0}
|
||||
isSceneTransitionEntering={false}
|
||||
isSceneTransitionExiting={false}
|
||||
transitionSweepPx={320}
|
||||
sceneTransitionExitDurationS={0.2}
|
||||
sceneTransitionEntryDurationS={0.2}
|
||||
companionAnchorLeft="10%"
|
||||
companionAnchorBottom="20%"
|
||||
playerBottomOffsetPx={0}
|
||||
sceneTransitionPhase="idle"
|
||||
inBattle={true}
|
||||
onEntitySelect={null}
|
||||
playerLeft="20%"
|
||||
playerCharacter={createCharacter()}
|
||||
playerHp={100}
|
||||
playerMaxHp={100}
|
||||
effectivePlayerFacing="right"
|
||||
effectivePlayerAnimationState={AnimationState.IDLE}
|
||||
shouldShowPlayerDialogueIcon={false}
|
||||
dialogueIndicator={null}
|
||||
npcAffinityEffect={null}
|
||||
sceneCombatants={[hostileNpc]}
|
||||
monsters={[]}
|
||||
getHostileNpcOuterLeft={() => '70%'}
|
||||
groundBottom="18%"
|
||||
stageLiftPx={68}
|
||||
encounter={null}
|
||||
sideAnchor="15%"
|
||||
cameraAnchorX={0}
|
||||
monsterAnchorMeters={3.2}
|
||||
playerX={0}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('查看断桥匪首详情');
|
||||
expect(html).toContain('from-rose-500 to-red-400');
|
||||
});
|
||||
|
||||
it('does not render affinity effect on a different npc', () => {
|
||||
const html = renderEntityLayer('npc-other');
|
||||
|
||||
|
||||
@@ -98,6 +98,18 @@ interface GameCanvasEntityLayerProps {
|
||||
const SCENE_ACT_BACK_ROW_ANCHOR_X_METERS = RESOLVED_ENTITY_X_METERS + 1.08;
|
||||
const SCENE_ACT_BACK_ROW_OFFSET_PX = [62, -46] as const;
|
||||
|
||||
function buildFallbackCombatEncounter(hostileNpc: SceneHostileNpc): Encounter {
|
||||
return {
|
||||
id: hostileNpc.id,
|
||||
kind: 'npc',
|
||||
npcName: hostileNpc.name,
|
||||
npcDescription: hostileNpc.description,
|
||||
npcAvatar: '',
|
||||
context: hostileNpc.action,
|
||||
hostile: true,
|
||||
};
|
||||
}
|
||||
|
||||
function addCssPxOffset(value: string, offsetPx: number) {
|
||||
return offsetPx === 0 ? value : `calc(${value} + ${offsetPx}px)`;
|
||||
}
|
||||
@@ -440,8 +452,7 @@ export function GameCanvasEntityLayer({
|
||||
</motion.div>
|
||||
|
||||
{sceneCombatants.map((hostileNpc, index) => {
|
||||
const npcEncounter = hostileNpc.encounter;
|
||||
if (!npcEncounter) return null;
|
||||
const npcEncounter = hostileNpc.encounter ?? buildFallbackCombatEncounter(hostileNpc);
|
||||
const hostileRenderKey = [
|
||||
hostileNpc.id,
|
||||
npcEncounter.id ?? npcEncounter.npcName,
|
||||
|
||||
215
src/components/match3d-creation/Match3DAgentWorkspace.tsx
Normal file
215
src/components/match3d-creation/Match3DAgentWorkspace.tsx
Normal 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;
|
||||
105
src/components/match3d-creation/Match3DDraftReadyView.tsx
Normal file
105
src/components/match3d-creation/Match3DDraftReadyView.tsx
Normal 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;
|
||||
103
src/components/match3d-result/Match3DResultView.test.tsx
Normal file
103
src/components/match3d-result/Match3DResultView.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
588
src/components/match3d-result/Match3DResultView.tsx
Normal file
588
src/components/match3d-result/Match3DResultView.tsx
Normal 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;
|
||||
1
src/components/match3d-result/index.ts
Normal file
1
src/components/match3d-result/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Match3DResultView } from './Match3DResultView';
|
||||
179
src/components/match3d-runtime/Match3DRuntimeShell.test.tsx
Normal file
179
src/components/match3d-runtime/Match3DRuntimeShell.test.tsx
Normal 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);
|
||||
});
|
||||
767
src/components/match3d-runtime/Match3DRuntimeShell.tsx
Normal file
767
src/components/match3d-runtime/Match3DRuntimeShell.tsx
Normal 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;
|
||||
1
src/components/match3d-runtime/index.ts
Normal file
1
src/components/match3d-runtime/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Match3DRuntimeShell } from './Match3DRuntimeShell';
|
||||
@@ -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
223
src/components/platform-entry/PlatformWorkDetailView.test.tsx
Normal file
223
src/components/platform-entry/PlatformWorkDetailView.test.tsx
Normal 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();
|
||||
});
|
||||
443
src/components/platform-entry/PlatformWorkDetailView.tsx
Normal file
443
src/components/platform-entry/PlatformWorkDetailView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -131,9 +131,9 @@ export function usePlatformCreationAgentFlowController<
|
||||
const [streamingReplyText, setStreamingReplyText] = useState('');
|
||||
const [isStreamingReply, setIsStreamingReply] = useState(false);
|
||||
|
||||
const openWorkspace = useCallback(async () => {
|
||||
const openWorkspace = useCallback(async (createPayload?: TCreatePayload) => {
|
||||
if (isBusy) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
setIsBusy(true);
|
||||
@@ -142,15 +142,20 @@ export function usePlatformCreationAgentFlowController<
|
||||
setIsStreamingReply(false);
|
||||
|
||||
try {
|
||||
const response = await options.client.createSession(options.createPayload);
|
||||
setSession(options.client.selectSession(response));
|
||||
const response = await options.client.createSession(
|
||||
createPayload ?? options.createPayload,
|
||||
);
|
||||
const nextSession = options.client.selectSession(response);
|
||||
setSession(nextSession);
|
||||
options.enterCreateTab();
|
||||
options.onSessionOpened?.();
|
||||
options.setSelectionStage(options.workspaceStage);
|
||||
return nextSession;
|
||||
} catch (caughtError) {
|
||||
setError(
|
||||
options.resolveErrorMessage(caughtError, options.errorMessages.open),
|
||||
);
|
||||
return null;
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
@@ -235,8 +240,9 @@ export function usePlatformCreationAgentFlowController<
|
||||
);
|
||||
|
||||
const executeAction = useCallback(
|
||||
async (payload: TActionPayload) => {
|
||||
if (!session || isBusy) {
|
||||
async (payload: TActionPayload, sessionOverride?: TSession | null) => {
|
||||
const targetSession = sessionOverride ?? session;
|
||||
if (!targetSession || isBusy) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -244,15 +250,15 @@ export function usePlatformCreationAgentFlowController<
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
options.beforeExecuteAction?.({ payload, session });
|
||||
options.beforeExecuteAction?.({ payload, session: targetSession });
|
||||
const response = await options.client.executeAction(
|
||||
session.sessionId,
|
||||
targetSession.sessionId,
|
||||
payload,
|
||||
);
|
||||
await options.onActionComplete?.({
|
||||
payload,
|
||||
response,
|
||||
session,
|
||||
session: targetSession,
|
||||
setSession,
|
||||
});
|
||||
if (options.isCompileAction(payload)) {
|
||||
|
||||
@@ -1,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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
276
src/components/puzzle-runtime/puzzleRuntimeShape.ts
Normal file
276
src/components/puzzle-runtime/puzzleRuntimeShape.ts
Normal 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;
|
||||
}
|
||||
@@ -219,7 +219,7 @@ export function RpgCreationRoleAnimationSection(props: {
|
||||
<ActionButton
|
||||
icon={<RefreshCcw className="h-4 w-4" />}
|
||||
label={isSelectedAnimationGenerating ? '生成中...' : '生成动作'}
|
||||
subLabel={`消耗${animationPointCost}叙世币`}
|
||||
subLabel={`消耗${animationPointCost}光点`}
|
||||
onClick={onGenerateAnimation}
|
||||
disabled={
|
||||
isSelectedAnimationGenerating ||
|
||||
|
||||
@@ -790,7 +790,7 @@ export function RpgCreationRoleAssetStudioModal({
|
||||
}
|
||||
|
||||
return window.confirm(
|
||||
`${params.kindLabel}预计消耗 ${params.points} 叙世币。\n${params.description}`,
|
||||
`${params.kindLabel}预计消耗 ${params.points} 光点。\n${params.description}`,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -166,7 +166,7 @@ export function RpgCreationRoleVisualSection(props: {
|
||||
? '重新生成角色形象'
|
||||
: '生成角色形象'
|
||||
}
|
||||
subLabel={`消耗${visualPointCost}叙世币`}
|
||||
subLabel={`消耗${visualPointCost}光点`}
|
||||
onClick={onGenerateVisuals}
|
||||
disabled={isGeneratingVisuals || isApplyingVisual || syncBusy}
|
||||
tone="sky"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -8,8 +8,10 @@ import { copyTextToClipboard } from '../../services/clipboard';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
import {
|
||||
buildPlatformWorldTags,
|
||||
buildPlatformWorldDisplayTags,
|
||||
describePlatformThemeLabel,
|
||||
formatPlatformWorkDisplayName,
|
||||
formatPlatformWorkDisplayTag,
|
||||
formatPlatformWorldTime,
|
||||
resolvePlatformPublicWorkCode,
|
||||
resolvePlatformWorldCoverImage,
|
||||
@@ -83,13 +85,8 @@ export function RpgEntryWorldDetailView({
|
||||
entry.profile,
|
||||
).slice(0, 3);
|
||||
const previewLandmarks = entry.profile.landmarks.slice(0, 3);
|
||||
const tags = [
|
||||
...new Set(
|
||||
buildPlatformWorldTags(entry)
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
].slice(0, 3);
|
||||
const displayName = formatPlatformWorkDisplayName(entry.worldName);
|
||||
const tags = buildPlatformWorldDisplayTags(entry, 3);
|
||||
const copyPublicWorkCode = () => {
|
||||
if (!publicWorkCode) {
|
||||
return;
|
||||
@@ -152,7 +149,9 @@ export function RpgEntryWorldDetailView({
|
||||
<div className="relative z-10">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="platform-pill platform-pill--warm">
|
||||
{describePlatformThemeLabel(entry.themeMode)}
|
||||
{formatPlatformWorkDisplayTag(
|
||||
describePlatformThemeLabel(entry.themeMode),
|
||||
)}
|
||||
</span>
|
||||
<span className="platform-pill platform-pill--neutral px-3">
|
||||
{entry.authorDisplayName}
|
||||
@@ -198,7 +197,7 @@ export function RpgEntryWorldDetailView({
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-4 text-3xl font-black text-white">
|
||||
{entry.worldName}
|
||||
{displayName}
|
||||
</div>
|
||||
{entry.subtitle ? (
|
||||
<div className="mt-2 text-sm tracking-[0.18em] text-zinc-300/88">
|
||||
|
||||
108
src/components/rpg-entry/rpgEntryWorldPresentation.test.ts
Normal file
108
src/components/rpg-entry/rpgEntryWorldPresentation.test.ts
Normal 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: '星港',
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -280,6 +280,7 @@ describe('RPG Agent 草稿恢复', () => {
|
||||
themeMode: 'tide',
|
||||
playableNpcCount: 0,
|
||||
landmarkCount: 0,
|
||||
likeCount: 0,
|
||||
},
|
||||
entries: [],
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -41,12 +41,12 @@ import type {
|
||||
WorldType,
|
||||
} from '../../types';
|
||||
import {
|
||||
CHROME_ICONS,
|
||||
getInventoryItemVisualSrc,
|
||||
getNineSliceStyle,
|
||||
UI_CHROME,
|
||||
} from '../../uiAssets';
|
||||
import { HostileNpcAnimator } from '../HostileNpcAnimator';
|
||||
import { PixelCloseButton } from '../PixelCloseButton';
|
||||
import { PixelIcon } from '../PixelIcon';
|
||||
|
||||
type AdventureStatisticCard = {
|
||||
@@ -961,13 +961,7 @@ export function RpgAdventurePanelOverlays({
|
||||
只展示这一步推进最需要知道的信息
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeGoalPanel}
|
||||
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
<PixelCloseButton onClick={closeGoalPanel} label="关闭任务更新" />
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4 scrollbar-hide">
|
||||
@@ -1037,13 +1031,10 @@ export function RpgAdventurePanelOverlays({
|
||||
调整音乐音量,查看统计数据,或保存并退出。
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
<PixelCloseButton
|
||||
onClick={() => setIsSettingsPanelOpen(false)}
|
||||
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
label="关闭冒险设置"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4">
|
||||
@@ -1162,13 +1153,10 @@ export function RpgAdventurePanelOverlays({
|
||||
当前区域: {statistics.currentSceneName}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
<PixelCloseButton
|
||||
onClick={() => setIsStatsPanelOpen(false)}
|
||||
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
label="关闭冒险统计"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4">
|
||||
@@ -1244,16 +1232,13 @@ export function RpgAdventurePanelOverlays({
|
||||
总任务数: {quests.length}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
<PixelCloseButton
|
||||
onClick={() => {
|
||||
setIsQuestPanelOpen(false);
|
||||
setSelectedQuestId(null);
|
||||
}}
|
||||
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
label="关闭任务日志"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-3 scrollbar-hide">
|
||||
@@ -1352,13 +1337,10 @@ export function RpgAdventurePanelOverlays({
|
||||
{selectedQuest.issuerNpcName}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
<PixelCloseButton
|
||||
onClick={() => setSelectedQuestId(null)}
|
||||
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
label="关闭任务详情"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4 scrollbar-hide sm:p-5">
|
||||
@@ -1543,18 +1525,15 @@ export function RpgAdventurePanelOverlays({
|
||||
{rewardQuest.title}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
<PixelCloseButton
|
||||
onClick={() => {
|
||||
setRewardQuestId(null);
|
||||
setRewardQuestHandoff(null);
|
||||
setSelectedRewardItemId(null);
|
||||
setSelectedRewardItemQuestId(null);
|
||||
}}
|
||||
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
label="关闭任务奖励"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
||||
@@ -1621,16 +1600,13 @@ export function RpgAdventurePanelOverlays({
|
||||
已击败敌人: {battleReward.defeatedHostileNpcs.length}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
<PixelCloseButton
|
||||
onClick={() => {
|
||||
battleRewardUi.dismiss();
|
||||
setSelectedBattleRewardItemId(null);
|
||||
}}
|
||||
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
label="关闭战斗奖励"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4">
|
||||
@@ -1716,17 +1692,14 @@ export function RpgAdventurePanelOverlays({
|
||||
{selectedRewardItem.category}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
<PixelCloseButton
|
||||
onClick={() => {
|
||||
setSelectedRewardItemId(null);
|
||||
setSelectedRewardItemQuestId(null);
|
||||
setSelectedBattleRewardItemId(null);
|
||||
}}
|
||||
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
label="关闭奖励物品"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4">
|
||||
|
||||
@@ -203,7 +203,6 @@ export function RpgRuntimePanelRouter({
|
||||
activeBuildBuffs={visibleGameState.activeBuildBuffs}
|
||||
companionRenderStates={companionRenderStates}
|
||||
npcStates={visibleGameState.npcStates}
|
||||
quests={visibleGameState.quests}
|
||||
companionArcStates={
|
||||
visibleGameState.storyEngineMemory?.companionArcStates ?? []
|
||||
}
|
||||
@@ -292,9 +291,6 @@ export function RpgRuntimePanelRouter({
|
||||
onCraftRecipe={inventoryUi.craftRecipe}
|
||||
onDismantleItem={inventoryUi.dismantleItem}
|
||||
onReforgeItem={inventoryUi.reforgeItem}
|
||||
continueGameDigest={
|
||||
visibleGameState.storyEngineMemory?.continueGameDigest ?? null
|
||||
}
|
||||
narrativeCodex={
|
||||
visibleGameState.storyEngineMemory?.narrativeCodex ?? []
|
||||
}
|
||||
|
||||
@@ -7,9 +7,9 @@ import type {
|
||||
StoryGenerationNpcUi,
|
||||
} from '../../hooks/rpg-runtime-story';
|
||||
import type { CompanionRenderState, GameState } from '../../types';
|
||||
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../../uiAssets';
|
||||
import { getNineSliceStyle, UI_CHROME } from '../../uiAssets';
|
||||
import type { GameCanvasEntitySelection } from '../GameCanvas';
|
||||
import { PixelIcon } from '../PixelIcon';
|
||||
import { PixelCloseButton } from '../PixelCloseButton';
|
||||
import {
|
||||
ModalLoadingFallback,
|
||||
PanelLoadingFallback,
|
||||
@@ -172,13 +172,7 @@ export function RpgRuntimeOverlayHost({
|
||||
<div className="min-w-0 pr-10 text-sm font-semibold text-white">
|
||||
{overlayPanel === 'character' ? '队伍' : '背包'}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeOverlayPanel}
|
||||
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
<PixelCloseButton onClick={closeOverlayPanel} label="关闭运行面板" />
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 p-5">
|
||||
{overlayPanel === 'character' ? (
|
||||
@@ -198,7 +192,6 @@ export function RpgRuntimeOverlayHost({
|
||||
activeBuildBuffs={gameState.activeBuildBuffs}
|
||||
companionRenderStates={companionRenderStates}
|
||||
npcStates={gameState.npcStates}
|
||||
quests={gameState.quests}
|
||||
onOpenCamp={() => {
|
||||
closeOverlayPanel();
|
||||
openCampModal();
|
||||
@@ -234,9 +227,6 @@ export function RpgRuntimeOverlayHost({
|
||||
onCraftRecipe={inventoryUi.craftRecipe}
|
||||
onDismantleItem={inventoryUi.dismantleItem}
|
||||
onReforgeItem={inventoryUi.reforgeItem}
|
||||
continueGameDigest={
|
||||
gameState.storyEngineMemory?.continueGameDigest ?? null
|
||||
}
|
||||
narrativeCodex={
|
||||
gameState.storyEngineMemory?.narrativeCodex ?? []
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user