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

# Conflicts:
#	docs/technical/README.md
#	docs/technical/RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md
#	docs/technical/SPACETIMEDB_TABLE_CATALOG.md
#	scripts/generate-spacetime-bindings.mjs
#	server-rs/crates/api-server/src/app.rs
#	server-rs/crates/api-server/src/assets.rs
#	server-rs/crates/api-server/src/big_fish.rs
#	server-rs/crates/api-server/src/custom_world_ai.rs
#	server-rs/crates/api-server/src/llm.rs
#	server-rs/crates/api-server/src/main.rs
#	server-rs/crates/api-server/src/puzzle.rs
#	server-rs/crates/api-server/src/runtime_profile.rs
#	server-rs/crates/api-server/src/runtime_story/compat/ai.rs
#	server-rs/crates/api-server/src/runtime_story/compat/npc_actions.rs
#	server-rs/crates/api-server/src/runtime_story/compat/presentation.rs
#	server-rs/crates/api-server/src/runtime_story/compat/tests.rs
#	server-rs/crates/api-server/src/state.rs
#	server-rs/crates/module-auth/src/lib.rs
#	server-rs/crates/module-big-fish/src/lib.rs
#	server-rs/crates/module-custom-world/src/lib.rs
#	server-rs/crates/module-puzzle/src/lib.rs
#	server-rs/crates/module-runtime/src/lib.rs
#	server-rs/crates/spacetime-client/src/big_fish.rs
#	server-rs/crates/spacetime-client/src/lib.rs
#	server-rs/crates/spacetime-client/src/mapper.rs
#	server-rs/crates/spacetime-client/src/module_bindings/admin_disable_profile_redeem_code_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_redeem_code_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/advance_puzzle_next_level_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/append_ai_text_chunk_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/apply_chapter_progression_ledger_entry_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/attach_ai_result_reference_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/authorize_database_migration_operator_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/begin_story_session_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/big_fish_runtime_run_type.rs
#	server-rs/crates/spacetime-client/src/module_bindings/bind_asset_object_to_entity_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/cancel_ai_task_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/clear_platform_browse_history_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/compile_big_fish_draft_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/compile_custom_world_published_profile_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/compile_puzzle_agent_draft_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/complete_ai_stage_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/complete_ai_task_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/confirm_asset_object_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/consume_profile_wallet_points_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/continue_story_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/create_ai_task_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/create_battle_state_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/create_big_fish_session_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/create_custom_world_agent_session_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/create_profile_recharge_order_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/create_puzzle_agent_session_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/delete_big_fish_work_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/delete_custom_world_agent_session_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/delete_custom_world_profile_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/delete_puzzle_work_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/delete_runtime_snapshot_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/drag_puzzle_piece_or_group_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/execute_custom_world_agent_action_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/export_auth_store_snapshot_from_tables_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/export_database_migration_to_file_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/fail_ai_task_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/finalize_big_fish_agent_message_turn_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/finalize_custom_world_agent_message_turn_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/finalize_puzzle_agent_message_turn_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/generate_big_fish_asset_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_auth_store_snapshot_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_battle_state_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_big_fish_session_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_chapter_progression_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_card_detail_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_operation_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_session_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_gallery_detail_by_code_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_gallery_detail_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_library_detail_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_player_progression_or_default_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_profile_dashboard_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_profile_play_stats_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_profile_recharge_center_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_profile_referral_invite_center_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_agent_session_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_gallery_detail_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_run_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_work_detail_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_runtime_inventory_state_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_runtime_setting_or_default_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_runtime_snapshot_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_story_session_state_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/grant_player_progression_experience_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/import_auth_store_snapshot_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_from_file_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_incremental_from_file_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_asset_history_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_big_fish_works_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_custom_world_gallery_entries_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_custom_world_profiles_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_custom_world_works_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_platform_browse_history_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_profile_save_archives_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_profile_wallet_ledger_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_puzzle_gallery_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_puzzle_works_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/mod.rs
#	server-rs/crates/spacetime-client/src/module_bindings/publish_big_fish_game_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/publish_custom_world_profile_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/publish_custom_world_world_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/publish_puzzle_work_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/record_big_fish_play_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/redeem_profile_referral_invite_code_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/redeem_profile_reward_code_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/refund_profile_wallet_points_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/resolve_combat_action_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_battle_interaction_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_interaction_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_social_action_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/resolve_treasure_interaction_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/resume_profile_save_archive_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/revoke_database_migration_operator_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/save_puzzle_generated_images_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/select_puzzle_cover_image_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/start_puzzle_run_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/submit_big_fish_message_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/submit_custom_world_agent_message_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/submit_puzzle_agent_message_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/submit_puzzle_leaderboard_entry_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/swap_puzzle_pieces_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/unpublish_custom_world_profile_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/update_puzzle_work_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_auth_store_snapshot_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_chapter_progression_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_custom_world_agent_operation_progress_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_custom_world_profile_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_npc_state_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_platform_browse_history_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_runtime_setting_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_runtime_snapshot_and_return_procedure.rs
#	server-rs/crates/spacetime-module/src/auth/procedures.rs
#	server-rs/crates/spacetime-module/src/custom_world/mod.rs
#	server-rs/crates/spacetime-module/src/lib.rs
#	server-rs/crates/spacetime-module/src/migration.rs
#	server-rs/crates/spacetime-module/src/puzzle.rs
#	server-rs/crates/spacetime-module/src/runtime/profile.rs
#	src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
#	src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx
#	src/services/aiService.ts
#	src/services/puzzle-runtime/puzzleRuntimeClient.ts
This commit is contained in:
kdletters
2026-05-02 03:35:59 +08:00
513 changed files with 52813 additions and 6013 deletions
+10 -22
View File
@@ -1,4 +1,3 @@
import { X } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import type { ReactNode } from 'react';
import { useEffect, useMemo, useState } from 'react';
@@ -88,6 +87,7 @@ import {
InventoryItemGrid,
} from './InventoryItemViews';
import { MedievalNpcAnimator } from './MedievalNpcAnimator';
import { PixelCloseButton } from './PixelCloseButton';
import { ResolvedAssetImage } from './ResolvedAssetImage';
import { SkillEffectPreview } from './SkillEffectPreview';
@@ -957,8 +957,8 @@ export function AdventureEntityModal({
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
<div>
<div className="relative flex items-center justify-between border-b border-white/10 px-5 py-4">
<div className="min-w-0 pr-10">
<div className="text-[10px] uppercase tracking-[0.24em] text-zinc-500">
详情
</div>
@@ -975,13 +975,7 @@ export function AdventureEntityModal({
/>
) : null}
</div>
<button
type="button"
onClick={onClose}
className="rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white"
>
<X className="h-4 w-4" />
</button>
<PixelCloseButton onClick={onClose} label="关闭冒险详情" />
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4 sm:p-5">
@@ -1319,13 +1313,10 @@ export function AdventureEntityModal({
{detailCharacter.name}
</div>
</div>
<button
type="button"
<PixelCloseButton
onClick={() => setSelectedContributionLabel(null)}
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
>
<X className="h-4 w-4" />
</button>
label="关闭标签效果"
/>
</div>
<div className="overflow-y-auto p-4 sm:p-5">
@@ -1431,13 +1422,10 @@ export function AdventureEntityModal({
{selectedSkillOwnerName}
</div>
</div>
<button
type="button"
<PixelCloseButton
onClick={() => setSelectedSkillId(null)}
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
>
<X className="h-4 w-4" />
</button>
label="关闭技能详情"
/>
</div>
<div className="space-y-4 overflow-y-auto p-4 sm:p-5">
+6 -8
View File
@@ -2,8 +2,8 @@ import { AnimatePresence, motion } from 'motion/react';
import { useEffect, useRef } from 'react';
import type { CharacterChatModalState } from '../hooks/rpg-runtime-story';
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { PixelIcon } from './PixelIcon';
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { PixelCloseButton } from './PixelCloseButton';
interface CharacterChatModalProps {
modal: CharacterChatModalState | null;
@@ -56,13 +56,11 @@ export function CharacterChatModal({
{modal.target.character.title} / {modal.target.roleLabel}
</div>
</div>
<button
type="button"
<PixelCloseButton
onClick={onClose}
className="rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white"
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
label="关闭角色聊天"
placement="inline"
/>
</div>
<div className="grid min-h-0 flex-1 gap-4 overflow-y-auto p-4 sm:grid-cols-[minmax(0,0.88fr)_minmax(0,1.12fr)] sm:overflow-hidden sm:p-5">
+2 -10
View File
@@ -23,7 +23,6 @@ import {
type WorldType,
} from '../types';
import {
CHROME_ICONS,
getNineSliceStyle,
type NineSliceTexture,
UI_CHROME,
@@ -38,7 +37,7 @@ import {
CharacterSkillsList,
} from './CharacterInfoShared';
import { MedievalNpcAnimator } from './MedievalNpcAnimator';
import { PixelIcon } from './PixelIcon';
import { PixelCloseButton } from './PixelCloseButton';
interface CharacterDetailModalProps {
character: Character | null;
@@ -194,14 +193,7 @@ export function CharacterDetailModal({
{subtitle}
</div>
</div>
<button
type="button"
onClick={onClose}
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
aria-label="关闭角色详情"
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
<PixelCloseButton onClick={onClose} label="关闭角色详情" />
</div>
<div className="grid min-h-0 flex-1 gap-4 overflow-y-auto p-5 lg:grid-cols-[minmax(0,0.82fr)_minmax(0,1.18fr)] lg:overflow-hidden">
+8 -45
View File
@@ -1,7 +1,6 @@
import { AnimatePresence, motion } from 'motion/react';
import { useEffect, useMemo, useState } from 'react';
import { normalizePlayerProgressionState } from '../data/playerProgression';
import {
resolveAttributeSchema,
resolveCharacterAttributeProfile,
@@ -27,6 +26,7 @@ import {
getEquipmentSlotLabel,
} from '../data/equipmentEffects';
import { buildMedievalNpcVisualFromCustomWorldVisual } from '../data/medievalNpcVisuals';
import { normalizePlayerProgressionState } from '../data/playerProgression';
import type { CharacterChatTarget } from '../hooks/rpg-runtime-story';
import { getResourceLabelsForWorld } from '../services/customWorldPresentation';
import {
@@ -38,12 +38,10 @@ import {
CustomWorldProfile,
EquipmentLoadout,
GameState,
QuestLogEntry,
TimedBuildBuff,
WorldType,
} from '../types';
import {
CHROME_ICONS,
getEquipmentSlotIcon,
getNineSliceStyle,
UI_CHROME,
@@ -66,6 +64,7 @@ import {
} from './CharacterInfoShared';
import type { GameCanvasEntitySelection } from './GameCanvas';
import { MedievalNpcAnimator } from './MedievalNpcAnimator';
import { PixelCloseButton } from './PixelCloseButton';
import { PixelIcon } from './PixelIcon';
import { ResolvedAssetImage } from './ResolvedAssetImage';
@@ -82,7 +81,6 @@ interface CharacterPanelProps {
activeBuildBuffs?: TimedBuildBuff[];
companionRenderStates: CompanionRenderState[];
npcStates?: GameState['npcStates'];
quests: QuestLogEntry[];
onOpenCamp?: () => void;
onOpenCharacterChat?: (target: CharacterChatTarget) => void;
chatSummaries?: Record<string, string>;
@@ -155,7 +153,6 @@ export function CharacterPanel({
activeBuildBuffs = [],
companionRenderStates,
npcStates = {},
quests,
onInspectMember,
companionArcStates = [],
companionResolutions = [],
@@ -215,11 +212,6 @@ export function CharacterPanel({
[partyMembers, selectedMemberId],
);
const activeQuests = useMemo(
() => quests.filter((quest) => quest.status !== 'turned_in'),
[quests],
);
const buildBreakdownByMemberId = useMemo(
() =>
Object.fromEntries(
@@ -374,29 +366,6 @@ export function CharacterPanel({
paddingY: 12,
})}
>
{activeQuests.length > 0 && (
<div className="mb-3 rounded-xl border border-sky-400/15 bg-sky-500/8 px-3 py-3">
<div className="mb-2 text-xs font-bold text-sky-100">
褰撳墠濮旀墭
</div>
<div className="space-y-2">
{activeQuests.map((quest) => (
<div
key={quest.id}
className="rounded-lg border border-white/6 bg-black/18 px-3 py-2 text-sm text-zinc-200"
>
<div className="font-semibold text-white">
{quest.title}
</div>
<div className="mt-1 text-xs text-zinc-400">
{quest.summary}
</div>
</div>
))}
</div>
</div>
)}
<div className="mb-3 text-xs font-bold text-white">队伍成员</div>
<div className="grid max-h-[calc(100vh-14rem)] grid-cols-1 gap-3 overflow-y-auto pr-1 scrollbar-hide sm:max-h-[calc(100vh-18rem)] md:grid-cols-2">
{partyMembers.map((member) => (
@@ -497,13 +466,10 @@ export function CharacterPanel({
{selectedMember.character.name}
</div>
</div>
<button
type="button"
<PixelCloseButton
onClick={() => setSelectedContributionLabel(null)}
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
label="关闭标签效果"
/>
</div>
<div className="overflow-y-auto p-4 sm:p-5">
@@ -619,13 +585,10 @@ export function CharacterPanel({
</span>
</div>
</div>
<button
type="button"
<PixelCloseButton
onClick={() => setSelectedMemberId(null)}
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
label="关闭角色详情"
/>
</div>
<div className="grid min-h-0 flex-1 gap-4 overflow-y-auto p-4 sm:p-5 lg:grid-cols-[minmax(0,0.82fr)_minmax(0,1.18fr)] lg:overflow-hidden">
+3 -9
View File
@@ -4,8 +4,8 @@ import { useEffect, useMemo, useState } from 'react';
import { getCharacterById } from '../data/characterPresets';
import { MAX_COMPANIONS } from '../data/npcInteractions';
import { Character, CompanionState } from '../types';
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { PixelIcon } from './PixelIcon';
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { PixelCloseButton } from './PixelCloseButton';
import { ResolvedAssetImage } from './ResolvedAssetImage';
interface CompanionCampModalProps {
@@ -145,13 +145,7 @@ export function CompanionCampModal({
{playerCharacter ? `${playerCharacter.name} / 出战 ${companions.length}/${MAX_COMPANIONS}` : '队伍调度'}
</div>
</div>
<button
type="button"
onClick={onClose}
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
<PixelCloseButton onClick={onClose} label="关闭营地编组" />
</div>
<div className="grid min-h-0 flex-1 gap-4 overflow-y-auto p-5 lg:grid-cols-[1.05fr_0.95fr] lg:overflow-hidden">
+1 -1
View File
@@ -1099,7 +1099,7 @@ export function CustomWorldEntityCatalog({
<div className="flex flex-wrap items-center gap-2 px-1">
{lockedCharacterNames.has(role.name.trim()) ? (
<span className="platform-pill platform-pill--warm px-2.5 py-1 text-[10px]">
创作者锁定
百梦主锁定
</span>
) : null}
<span className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">
@@ -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: '结束预览' }));
+2 -8
View File
@@ -5,11 +5,11 @@ import { resolveInventoryItemUseEffect } from '../data/inventoryEffects';
import { buildInventoryItemDescription } from '../data/itemPresentation';
import type { Character, InventoryItem, WorldType } from '../types';
import {
CHROME_ICONS,
getInventoryItemVisualSrc,
getNineSliceStyle,
UI_CHROME,
} from '../uiAssets';
import { PixelCloseButton } from './PixelCloseButton';
import { PixelIcon } from './PixelIcon';
function getInventoryRarityTheme(rarity: InventoryItem['rarity']) {
@@ -185,13 +185,7 @@ export function InventoryItemDetailModal({
onClick={(event) => event.stopPropagation()}
>
<div className="relative flex min-h-0 flex-1 flex-col gap-4 p-4 sm:gap-5 sm:p-5">
<button
type="button"
onClick={onClose}
className="absolute right-4 top-4 z-10 rounded-full border border-white/10 bg-black/25 p-1.5 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-5"
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
<PixelCloseButton onClick={onClose} label="关闭物品详情" className="top-4 sm:top-5" />
<div
className={`relative overflow-hidden rounded-[1.5rem] border px-4 py-5 sm:px-6 sm:py-6 ${rarityTheme.frameClass}`}
-10
View File
@@ -38,7 +38,6 @@ interface InventoryPanelProps {
onCraftRecipe: (recipeId: string) => Promise<boolean>;
onDismantleItem: (itemId: string) => Promise<boolean>;
onReforgeItem: (itemId: string) => Promise<boolean>;
continueGameDigest?: string | null;
narrativeCodex?: NarrativeCodexSection[];
narrativeQaReport?: NarrativeQaReport | null;
}
@@ -58,7 +57,6 @@ export function InventoryPanel({
onCraftRecipe,
onDismantleItem: _onDismantleItem,
onReforgeItem: _onReforgeItem,
continueGameDigest = null,
narrativeCodex = [],
narrativeQaReport = null,
}: InventoryPanelProps) {
@@ -92,14 +90,6 @@ export function InventoryPanel({
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex-1 overflow-y-auto scrollbar-hide">
{continueGameDigest && (
<div className="mb-4 rounded-2xl border border-white/10 bg-black/20 p-4 text-xs leading-relaxed text-zinc-300">
<div className="mb-2 text-[10px] uppercase tracking-[0.22em] text-zinc-500">
旅程回顾
</div>
{continueGameDigest}
</div>
)}
<InventoryItemGrid
items={inventoryItems}
selectedItemId={selectedItem?.id ?? null}
+5 -13
View File
@@ -6,6 +6,7 @@ import { getConnectedScenePresets } from '../data/scenePresets';
import { useResolvedAssetReadUrl } from '../hooks/useResolvedAssetReadUrl';
import { ScenePresetInfo, WorldType } from '../types';
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { PixelCloseButton } from './PixelCloseButton';
import { PixelIcon } from './PixelIcon';
function buildSceneBackdropStyle(imageSrc?: string | null): CSSProperties {
@@ -252,13 +253,7 @@ export function MapModal({
<span>地图</span>
</div>
</div>
<button
type="button"
onClick={onClose}
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
<PixelCloseButton onClick={onClose} label="关闭地图" />
</div>
<div className="relative grid min-h-0 flex-1 gap-4 overflow-y-auto p-4 sm:p-5 md:grid-cols-[minmax(15rem,18rem)_minmax(0,1fr)] md:overflow-hidden">
@@ -385,13 +380,10 @@ export function MapModal({
<div className="text-[10px] tracking-[0.22em] text-amber-200/80">场景切换</div>
<div className="mt-1 truncate text-sm font-semibold text-white">{pendingScene.scene.name}</div>
</div>
<button
type="button"
<PixelCloseButton
onClick={() => setPendingScene(null)}
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
label="关闭场景切换"
/>
</div>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4 sm:p-5">
+20 -19
View File
@@ -25,7 +25,8 @@ import {
RuntimeNpcGiftItemView,
RuntimeNpcTradeItemView,
} from '../types';
import { CHROME_ICONS, getInventoryItemVisualSrc, getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { getInventoryItemVisualSrc, getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { PixelCloseButton } from './PixelCloseButton';
import { PixelIcon } from './PixelIcon';
interface NpcModalsProps {
@@ -232,13 +233,11 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
{npcInteraction?.npcName ?? tradeModal.encounter.npcName} / 你当前{currencyName}:{npcInteraction?.playerCurrency ?? gameState.playerCurrency}
</div>
</div>
<button
type="button"
<PixelCloseButton
onClick={npcUi.closeTradeModal}
className="rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white"
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
label="关闭交易"
placement="inline"
/>
</div>
<div className="flex-1 overflow-y-auto px-4 py-3 sm:px-5 sm:py-4">
@@ -385,13 +384,11 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
{tradeDetail.source === 'buy' ? '对方物品' : '背包物品'}
</div>
</div>
<button
type="button"
<PixelCloseButton
onClick={() => setTradeDetail(null)}
className="rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white"
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
label="关闭物品详情"
placement="inline"
/>
</div>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-5">
@@ -474,9 +471,11 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
<div className="text-sm font-semibold text-white">赠送礼物</div>
<div className="mt-1 text-xs text-zinc-500">{npcUi.giftModal.encounter.npcName}</div>
</div>
<button type="button" onClick={npcUi.closeGiftModal} className="rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white">
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
<PixelCloseButton
onClick={npcUi.closeGiftModal}
label="关闭赠礼"
placement="inline"
/>
</div>
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-5">
@@ -550,9 +549,11 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
<div className="text-sm font-semibold text-white">调整同行位置</div>
<div className="mt-1 text-xs text-zinc-500">队伍已满,请先选择一名当前同行角色离队,再邀请对方加入。</div>
</div>
<button type="button" onClick={npcUi.closeRecruitModal} className="rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white">
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
<PixelCloseButton
onClick={npcUi.closeRecruitModal}
label="关闭招募"
placement="inline"
/>
</div>
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-5">
+45
View File
@@ -0,0 +1,45 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest';
import { PixelCloseButton } from './PixelCloseButton';
test('pixel close button closes without bubbling to the overlay', async () => {
const user = userEvent.setup();
const onClose = vi.fn();
const onOverlayClick = vi.fn();
render(
<div onClick={onOverlayClick}>
<PixelCloseButton onClick={onClose} label="关闭测试面板" />
</div>,
);
await user.click(screen.getByRole('button', { name: '关闭测试面板' }));
expect(onClose).toHaveBeenCalledTimes(1);
expect(onOverlayClick).not.toHaveBeenCalled();
});
test('inline pixel close button keeps the same click boundary', async () => {
const user = userEvent.setup();
const onClose = vi.fn();
const onHeaderClick = vi.fn();
render(
<div onClick={onHeaderClick}>
<PixelCloseButton
onClick={onClose}
label="关闭标题栏面板"
placement="inline"
/>
</div>,
);
await user.click(screen.getByRole('button', { name: '关闭标题栏面板' }));
expect(onClose).toHaveBeenCalledTimes(1);
expect(onHeaderClick).not.toHaveBeenCalled();
});
+45
View File
@@ -0,0 +1,45 @@
import type { MouseEvent } from 'react';
import { CHROME_ICONS } from '../uiAssets';
import { PixelIcon } from './PixelIcon';
type PixelCloseButtonProps = {
onClick: () => void;
label?: string;
placement?: 'absolute' | 'inline';
className?: string;
};
/**
* RPG 像素风弹窗右上关闭按钮。
* 统一拦截点击冒泡,避免历史手写 overlay / panel 的点击处理影响关闭行为。
*/
export function PixelCloseButton({
onClick,
label = '关闭面板',
placement = 'absolute',
className = '',
}: PixelCloseButtonProps) {
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
onClick();
};
const placementClassName =
placement === 'absolute'
? 'absolute right-4 top-3 sm:right-5 sm:top-4'
: 'relative shrink-0';
return (
<button
type="button"
aria-label={label}
title={label}
onClick={handleClick}
className={`${placementClassName} z-20 flex h-9 w-9 items-center justify-center rounded-full border border-white/10 bg-black/30 p-0 text-zinc-400 shadow-[0_8px_18px_rgba(0,0,0,0.28)] transition-colors hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-200/70 ${className}`.trim()}
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
);
}
+22 -3
View File
@@ -16,15 +16,18 @@ const baseUser: AuthUser = {
id: 'user-1',
username: 'tester',
displayName: '138****8000',
avatarUrl: null,
publicUserCode: 'user-tester',
phoneNumberMasked: '138****8000',
loginMethod: 'phone',
bindingStatus: 'active',
wechatBound: true,
createdAt: new Date().toISOString(),
};
function renderAccountModal(overrides?: {
user?: AuthUser;
entryMode?: 'settings' | 'account';
riskBlocks?: AuthRiskBlockSummary[];
sessions?: AuthSessionSummary[];
auditLogs?: AuthAuditLogEntry[];
@@ -40,6 +43,7 @@ function renderAccountModal(overrides?: {
<AccountModal
user={overrides?.user ?? baseUser}
isOpen
entryMode={overrides?.entryMode ?? 'settings'}
initialSection={overrides?.initialSection ?? null}
platformTheme="light"
riskBlocks={overrides?.riskBlocks ?? []}
@@ -90,6 +94,21 @@ test('settings header uses a generic title instead of the phone number', () => {
expect(screen.queryByRole('button', { name: '退出全部设备' })).toBeNull();
});
test('direct account entry does not render the settings shell as another dialog', () => {
renderAccountModal({ entryMode: 'account' });
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
expect(accountDialog).toBeTruthy();
expect(screen.queryByRole('dialog', { name: '设置与账号安全' })).toBeNull();
expect(screen.queryByText('设置与账号安全')).toBeNull();
expect(
within(accountDialog).getByRole('button', { name: '关闭' }),
).toBeTruthy();
expect(
within(accountDialog).queryByRole('button', { name: '返回' }),
).toBeNull();
});
test('account actions open in independent panels instead of inline expansion', async () => {
const user = userEvent.setup();
@@ -130,9 +149,9 @@ test('nested settings panels keep back navigation without an extra close action'
expect(
within(accountDialog).getByRole('button', { name: '返回' }),
).toBeTruthy();
expect(
accountHeader?.lastElementChild?.textContent?.includes('返回'),
).toBe(true);
expect(accountHeader?.lastElementChild?.textContent?.includes('返回')).toBe(
true,
);
expect(
within(accountDialog).queryByRole('button', { name: '关闭' }),
).toBeNull();
+124 -87
View File
@@ -20,6 +20,7 @@ import { CaptchaChallengeField } from './CaptchaChallengeField';
type AccountModalProps = {
user: AuthUser;
isOpen: boolean;
entryMode?: 'settings' | 'account';
initialSection?: PlatformSettingsSection | null;
platformTheme: PlatformTheme;
riskBlocks: AuthRiskBlockSummary[];
@@ -159,6 +160,7 @@ function OverlayPanel({
title,
description,
action,
standalone = false,
onBack,
onClose,
children,
@@ -167,64 +169,73 @@ function OverlayPanel({
title: string;
description?: string;
action?: ReactNode;
standalone?: boolean;
onBack?: () => void;
onClose: () => void;
children: ReactNode;
}) {
const panel = (
<div
className="platform-auth-card flex max-h-full w-full min-h-0 flex-col overflow-hidden rounded-[28px] p-5 sm:max-w-3xl sm:p-6"
role="dialog"
aria-modal="true"
aria-label={title}
style={{ maxHeight: ACCOUNT_MODAL_MAX_HEIGHT }}
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="text-xs uppercase tracking-[0.28em] text-[var(--platform-cool-text)]">
{eyebrow}
</div>
<div className="mt-2 text-2xl font-semibold text-[var(--platform-text-strong)]">
{title}
</div>
{description ? (
<div className="mt-2 text-sm text-[var(--platform-text-base)]">
{description}
</div>
) : null}
</div>
<div className="flex items-center gap-2">
{action}
{onBack ? (
<button
type="button"
autoFocus
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs"
onClick={onBack}
>
返回
</button>
) : (
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs"
onClick={onClose}
>
关闭
</button>
)}
</div>
</div>
<div className="mt-5 min-h-0 flex-1 overflow-y-auto overscroll-y-contain pr-1">
{children}
</div>
</div>
);
if (standalone) {
return panel;
}
return (
<div
className="absolute inset-0 z-10 flex items-end bg-black/20 backdrop-blur-[2px] sm:items-center sm:justify-center sm:p-4"
onClick={onBack ?? onClose}
>
<div
className="platform-auth-card flex max-h-full w-full min-h-0 flex-col overflow-hidden rounded-[28px] p-5 sm:max-w-3xl sm:p-6"
role="dialog"
aria-modal="true"
aria-label={title}
style={{ maxHeight: ACCOUNT_MODAL_MAX_HEIGHT }}
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="text-xs uppercase tracking-[0.28em] text-[var(--platform-cool-text)]">
{eyebrow}
</div>
<div className="mt-2 text-2xl font-semibold text-[var(--platform-text-strong)]">
{title}
</div>
{description ? (
<div className="mt-2 text-sm text-[var(--platform-text-base)]">
{description}
</div>
) : null}
</div>
<div className="flex items-center gap-2">
{action}
{onBack ? (
<button
type="button"
autoFocus
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs"
onClick={onBack}
>
返回
</button>
) : (
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs"
onClick={onClose}
>
关闭
</button>
)}
</div>
</div>
<div className="mt-5 min-h-0 flex-1 overflow-y-auto overscroll-y-contain pr-1">
{children}
</div>
</div>
{panel}
</div>
);
}
@@ -266,6 +277,7 @@ function ThemeOptionCard({
export function AccountModal({
user,
isOpen,
entryMode = 'settings',
initialSection = null,
platformTheme,
riskBlocks,
@@ -314,6 +326,7 @@ export function AccountModal({
const sectionTriggerRef = useRef<HTMLButtonElement | null>(null);
const changePhoneTriggerRef = useRef<HTMLButtonElement | null>(null);
const passwordTriggerRef = useRef<HTMLButtonElement | null>(null);
const isDirectAccountMode = entryMode === 'account';
const focusAfterNextPaint = useCallback((element: HTMLElement | null) => {
if (!element) {
@@ -347,7 +360,11 @@ export function AccountModal({
return;
}
setActiveSection(normalizeSettingsSection(initialSection));
setActiveSection(
isDirectAccountMode
? 'account'
: normalizeSettingsSection(initialSection),
);
setIsChangePhonePanelOpen(false);
setIsPasswordPanelOpen(false);
setAccountNotice('');
@@ -356,7 +373,13 @@ export function AccountModal({
passwordTriggerRef.current = null;
resetChangePhoneDraft();
resetPasswordDraft();
}, [initialSection, isOpen, resetChangePhoneDraft, resetPasswordDraft]);
}, [
initialSection,
isDirectAccountMode,
isOpen,
resetChangePhoneDraft,
resetPasswordDraft,
]);
useEffect(() => {
const settingsHome = settingsHomeRef.current;
@@ -446,47 +469,55 @@ export function AccountModal({
onClick={onClose}
>
<div
className="platform-auth-card relative flex h-[min(100%,calc(100vh-2rem))] w-full max-w-5xl min-h-0 flex-col overflow-hidden rounded-[28px] p-5 sm:p-6"
role="dialog"
aria-modal="true"
aria-label="设置与账号安全"
className={
isDirectAccountMode
? 'relative flex max-h-full w-full max-w-3xl min-h-0 flex-col overflow-hidden'
: 'platform-auth-card relative flex h-[min(100%,calc(100vh-2rem))] w-full max-w-5xl min-h-0 flex-col overflow-hidden rounded-[28px] p-5 sm:p-6'
}
role={isDirectAccountMode ? undefined : 'dialog'}
aria-modal={isDirectAccountMode ? undefined : true}
aria-label={isDirectAccountMode ? undefined : '设置与账号安全'}
style={{ maxHeight: ACCOUNT_MODAL_MAX_HEIGHT }}
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-start justify-between gap-4">
<div>
<div className="text-2xl font-semibold text-[var(--platform-text-strong)]">
设置与账号安全
{!isDirectAccountMode ? (
<div className="flex items-start justify-between gap-4">
<div>
<div className="text-2xl font-semibold text-[var(--platform-text-strong)]">
设置与账号安全
</div>
</div>
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs"
onClick={onClose}
>
关闭
</button>
</div>
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs"
onClick={onClose}
>
关闭
</button>
</div>
) : null}
<div className="mt-5 min-h-0 flex-1 overflow-y-auto overscroll-y-contain pr-1">
<div ref={settingsHomeRef} className="flex min-h-0 flex-col gap-4">
<div className="grid gap-3 sm:grid-cols-2">
{SETTINGS_SECTIONS.map((section) => (
<SettingsEntryCard
key={section.id}
label={section.label}
detail={section.detail}
summary={sectionSummaries[section.id]}
onClick={(trigger) => {
sectionTriggerRef.current = trigger;
setAccountNotice('');
setActiveSection(section.id);
}}
/>
))}
{!isDirectAccountMode ? (
<div className="mt-5 min-h-0 flex-1 overflow-y-auto overscroll-y-contain pr-1">
<div ref={settingsHomeRef} className="flex min-h-0 flex-col gap-4">
<div className="grid gap-3 sm:grid-cols-2">
{SETTINGS_SECTIONS.map((section) => (
<SettingsEntryCard
key={section.id}
label={section.label}
detail={section.detail}
summary={sectionSummaries[section.id]}
onClick={(trigger) => {
sectionTriggerRef.current = trigger;
setAccountNotice('');
setActiveSection(section.id);
}}
/>
))}
</div>
</div>
</div>
</div>
) : null}
{activeSection === 'appearance' ? (
<OverlayPanel
@@ -538,7 +569,8 @@ export function AccountModal({
eyebrow="身份信息"
title="账号信息"
description="统一查看身份、安全状态、登录设备与最近操作。"
onBack={closeSectionPanel}
standalone={isDirectAccountMode}
onBack={isDirectAccountMode ? undefined : closeSectionPanel}
onClose={onClose}
>
<div className="flex min-h-0 flex-col gap-4">
@@ -671,7 +703,10 @@ export function AccountModal({
<span>{block.title}</span>
<span className="text-xs">
剩余约{' '}
{Math.max(1, Math.ceil(block.remainingSeconds / 60))}{' '}
{Math.max(
1,
Math.ceil(block.remainingSeconds / 60),
)}{' '}
分钟
</span>
</div>
@@ -965,7 +1000,9 @@ export function AccountModal({
type="password"
autoComplete="current-password"
placeholder="首次设置可留空"
onChange={(event) => setCurrentPassword(event.target.value)}
onChange={(event) =>
setCurrentPassword(event.target.value)
}
/>
</label>
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
+126 -2
View File
@@ -18,6 +18,7 @@ const authMocks = vi.hoisted(() => ({
loginWithPhoneCode: vi.fn(),
logoutAllAuthSessions: vi.fn(),
logoutAuthUser: vi.fn(),
redeemRegistrationInviteCode: vi.fn(),
resetPassword: vi.fn(),
sendPhoneLoginCode: vi.fn(),
startWechatLogin: vi.fn(),
@@ -46,6 +47,7 @@ vi.mock('../../services/authService', () => ({
loginWithPhoneCode: authMocks.loginWithPhoneCode,
logoutAllAuthSessions: authMocks.logoutAllAuthSessions,
logoutAuthUser: authMocks.logoutAuthUser,
redeemRegistrationInviteCode: authMocks.redeemRegistrationInviteCode,
resetPassword: authMocks.resetPassword,
revokeAuthSession: vi.fn(),
sendPhoneLoginCode: authMocks.sendPhoneLoginCode,
@@ -78,26 +80,53 @@ const mockUser: AuthUser = {
id: 'user-1',
username: 'tester',
displayName: '测试玩家',
avatarUrl: null,
publicUserCode: 'user-tester',
phoneNumberMasked: '138****8000',
loginMethod: 'phone',
bindingStatus: 'active',
wechatBound: false,
createdAt: new Date().toISOString(),
};
beforeEach(() => {
vi.clearAllMocks();
window.history.replaceState(null, '', '/');
authMocks.consumeAuthCallbackResult.mockReturnValue(null);
authMocks.ensureStoredAccessToken.mockResolvedValue('jwt-existing-token');
authMocks.getCurrentAuthUser.mockResolvedValue({
user: null,
availableLoginMethods: ['phone'],
});
authMocks.loginWithPhoneCode.mockResolvedValue(mockUser);
authMocks.loginWithPhoneCode.mockResolvedValue({
token: 'jwt-phone',
user: mockUser,
created: false,
referral: null,
});
authMocks.authEntry.mockResolvedValue(mockUser);
authMocks.changePassword.mockResolvedValue(mockUser);
authMocks.logoutAllAuthSessions.mockResolvedValue(undefined);
authMocks.logoutAuthUser.mockResolvedValue(undefined);
authMocks.redeemRegistrationInviteCode.mockResolvedValue({
center: {
inviteCode: 'SY12345678',
inviteLinkPath: '/?inviteCode=SY12345678',
invitedCount: 1,
rewardedInviteCount: 1,
todayInviterRewardCount: 0,
todayInviterRewardRemaining: 3,
rewardPoints: 30,
hasRedeemedCode: true,
boundInviterUserId: 'user_inviter',
boundAt: '2026-05-01T00:00:00Z',
updatedAt: '2026-05-01T00:00:00Z',
},
inviteeRewardGranted: true,
inviterRewardGranted: true,
inviteeBalanceAfter: 30,
inviterBalanceAfter: 30,
});
authMocks.resetPassword.mockResolvedValue(mockUser);
authMocks.sendPhoneLoginCode.mockResolvedValue({
cooldownSeconds: 60,
@@ -238,12 +267,15 @@ test('auth gate keeps password entry available when login options are empty', as
const dialog = screen.getByRole('dialog', { name: '账号入口' });
expect(within(dialog).getByLabelText('密码')).toBeTruthy();
expect(within(dialog).queryByText('当前登录入口暂不可用。')).toBeNull();
expect(within(dialog).queryByText('读取登录方式失败')).toBeNull();
});
test('auth gate falls back to password entry when login options request fails', async () => {
const user = userEvent.setup();
authMocks.getAuthLoginOptions.mockRejectedValue(new Error('读取登录方式失败'));
authMocks.getAuthLoginOptions.mockRejectedValue(
new Error('读取登录方式失败'),
);
render(
<AuthGate>
@@ -294,6 +326,98 @@ test('auth gate opens a login modal for protected actions and resumes after logi
expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull();
});
test('auth gate hides register entry and opens invite modal for new sms account', async () => {
const user = userEvent.setup();
window.history.replaceState(null, '', '/?inviteCode=spring-2026');
authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: ['phone'],
});
authMocks.loginWithPhoneCode.mockResolvedValueOnce({
token: 'jwt-phone-new',
user: mockUser,
created: true,
referral: null,
});
render(
<AuthGate>
<div>公开内容</div>
<ProtectedActionButton onAuthenticated={vi.fn()} />
</AuthGate>,
);
expect(await screen.findByText('公开内容')).toBeTruthy();
expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull();
await user.click(screen.getByRole('button', { name: '进入作品' }));
const dialog = await screen.findByRole('dialog', { name: '账号入口' });
expect(within(dialog).queryByRole('tab', { name: '注册' })).toBeNull();
expect(within(dialog).queryByLabelText('邀请码')).toBeNull();
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
await user.type(within(dialog).getByLabelText('验证码'), '123456');
await user.click(within(dialog).getByRole('button', { name: '登录' }));
await waitFor(() => {
expect(authMocks.loginWithPhoneCode).toHaveBeenCalledWith(
'13800000000',
'123456',
);
});
const inviteDialog = await screen.findByRole('dialog', {
name: '请填写邀请码',
});
expect(
(within(inviteDialog).getByLabelText('邀请码') as HTMLInputElement).value,
).toBe('SPRING2026');
expect(
within(inviteDialog).getByRole('button', { name: '提交' }),
).toBeTruthy();
await user.click(within(inviteDialog).getByRole('button', { name: '提交' }));
await waitFor(() => {
expect(authMocks.redeemRegistrationInviteCode).toHaveBeenCalledWith(
'SPRING2026',
);
});
});
test('registration invite modal can skip when invite code is empty', async () => {
const user = userEvent.setup();
authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: ['phone'],
});
authMocks.loginWithPhoneCode.mockResolvedValueOnce({
token: 'jwt-phone-new',
user: mockUser,
created: true,
referral: null,
});
render(
<AuthGate>
<ProtectedActionButton onAuthenticated={vi.fn()} />
</AuthGate>,
);
await user.click(await screen.findByRole('button', { name: '进入作品' }));
const dialog = screen.getByRole('dialog', { name: '账号入口' });
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
await user.type(within(dialog).getByLabelText('验证码'), '123456');
await user.click(within(dialog).getByRole('button', { name: '登录' }));
const inviteDialog = await screen.findByRole('dialog', {
name: '请填写邀请码',
});
await user.click(within(inviteDialog).getByRole('button', { name: '跳过' }));
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: '请填写邀请码' })).toBeNull();
});
expect(authMocks.redeemRegistrationInviteCode).not.toHaveBeenCalled();
});
test('auth state refresh keeps mounted platform content and local tab state', async () => {
const user = userEvent.setup();
authMocks.getCurrentAuthUser.mockResolvedValue({
+97 -12
View File
@@ -34,6 +34,7 @@ import {
loginWithPhoneCode,
logoutAllAuthSessions,
logoutAuthUser,
redeemRegistrationInviteCode,
resetPassword,
revokeAuthSession,
sendPhoneLoginCode,
@@ -44,6 +45,7 @@ import { AccountModal } from './AccountModal';
import { AuthUiContext, type PlatformSettingsSection } from './AuthUiContext';
import { BindPhoneScreen } from './BindPhoneScreen';
import { LoginScreen } from './LoginScreen';
import { RegistrationInviteModal } from './RegistrationInviteModal';
type AuthGateProps = {
children: ReactNode;
@@ -59,6 +61,14 @@ type AuthStatus =
const FALLBACK_LOGIN_METHODS: AuthLoginMethod[] = ['password'];
function readInviteCodeFromLocation(): string {
const params = new URLSearchParams(window.location.search || '');
return (params.get('inviteCode') || params.get('invite_code') || '')
.trim()
.replace(/[^0-9a-z]/gi, '')
.toUpperCase();
}
function normalizeAvailableLoginMethods(
methods: AuthLoginMethod[] | null | undefined,
): AuthLoginMethod[] {
@@ -83,7 +93,16 @@ export function AuthGate({ children }: AuthGateProps) {
const [bindingPhone, setBindingPhone] = useState(false);
const [wechatLoading, setWechatLoading] = useState(false);
const [showLoginModal, setShowLoginModal] = useState(false);
const [pendingInviteCode, setPendingInviteCode] = useState('');
const [showRegistrationInviteModal, setShowRegistrationInviteModal] =
useState(false);
const [submittingRegistrationInvite, setSubmittingRegistrationInvite] =
useState(false);
const [registrationInviteError, setRegistrationInviteError] = useState('');
const [showSettingsModal, setShowSettingsModal] = useState(false);
const [settingsEntryMode, setSettingsEntryMode] = useState<
'settings' | 'account'
>('settings');
const [initialSettingsSection, setInitialSettingsSection] =
useState<PlatformSettingsSection | null>(null);
const [sessions, setSessions] = useState<AuthSessionSummary[]>([]);
@@ -99,6 +118,7 @@ export function AuthGate({ children }: AuthGateProps) {
const [changePhoneCaptchaChallenge, setChangePhoneCaptchaChallenge] =
useState<AuthCaptchaChallenge | null>(null);
const pendingProtectedActionRef = useRef<(() => void) | null>(null);
const autoOpenedInviteCodeRef = useRef<string | null>(null);
const hasRenderedPlatformContentRef = useRef(false);
const canKeepPlatformContentMounted =
hasRenderedPlatformContentRef.current &&
@@ -125,7 +145,9 @@ export function AuthGate({ children }: AuthGateProps) {
setUser(null);
setStatus('unauthenticated');
setShowLoginModal(false);
setShowRegistrationInviteModal(false);
setShowSettingsModal(false);
setSettingsEntryMode('settings');
setInitialSettingsSection(null);
setSessions([]);
setAuditLogs([]);
@@ -133,6 +155,8 @@ export function AuthGate({ children }: AuthGateProps) {
setLoginCaptchaChallenge(null);
setBindCaptchaChallenge(null);
setChangePhoneCaptchaChallenge(null);
setPendingInviteCode('');
setRegistrationInviteError('');
setError('');
}, []);
@@ -169,6 +193,18 @@ export function AuthGate({ children }: AuthGateProps) {
setError('');
}, []);
const closeRegistrationInviteModal = useCallback(() => {
setShowRegistrationInviteModal(false);
setRegistrationInviteError('');
setPendingInviteCode('');
}, []);
const closeSettingsModal = useCallback(() => {
setShowSettingsModal(false);
setSettingsEntryMode('settings');
setInitialSettingsSection(null);
}, []);
const openLoginModal = useCallback(
(postLoginAction?: (() => void) | null) => {
if (readyUser) {
@@ -192,6 +228,7 @@ export function AuthGate({ children }: AuthGateProps) {
const openSettingsModal = useCallback(
(section?: PlatformSettingsSection) => {
if (readyUser) {
setSettingsEntryMode('settings');
setInitialSettingsSection(section ?? null);
setShowSettingsModal(true);
return;
@@ -203,13 +240,36 @@ export function AuthGate({ children }: AuthGateProps) {
);
const openAccountModal = useCallback(() => {
openSettingsModal('account');
}, [openSettingsModal]);
if (readyUser) {
setSettingsEntryMode('account');
setInitialSettingsSection('account');
setShowSettingsModal(true);
return;
}
openLoginModal();
}, [openLoginModal, readyUser]);
useEffect(() => {
if (status !== 'unauthenticated' || readyUser || showLoginModal) {
return;
}
const inviteCode = readInviteCodeFromLocation();
if (!inviteCode) {
return;
}
if (autoOpenedInviteCodeRef.current === inviteCode) {
return;
}
autoOpenedInviteCodeRef.current = inviteCode;
setPendingInviteCode(inviteCode);
}, [readyUser, showLoginModal, status]);
useEffect(() => {
let isActive = true;
const hydrate = async () => {
const callbackResult = consumeAuthCallbackResult();
const loadLoginOptions = async () => {
const options = await getAuthLoginOptions();
if (!isActive) {
@@ -224,7 +284,7 @@ export function AuthGate({ children }: AuthGateProps) {
const resolveGuestFallback = async () => {
try {
const options = await loadLoginOptions();
await loadLoginOptions();
if (!isActive) {
return;
}
@@ -238,16 +298,13 @@ export function AuthGate({ children }: AuthGateProps) {
setAvailableLoginMethods(FALLBACK_LOGIN_METHODS);
setUser(null);
setError(
optionsError instanceof Error
? optionsError.message
: '读取登录方式失败,请稍后再试。',
);
// 中文注释:登录方式接口失败时按产品约定保留密码登录入口;
// 这里不展示接口读取错误,避免用户误以为登录本身不可用。
setError(callbackResult?.error ?? '');
setStatus('unauthenticated');
}
};
const callbackResult = consumeAuthCallbackResult();
if (callbackResult?.error && isActive) {
setError(callbackResult.error);
setShowLoginModal(true);
@@ -410,6 +467,7 @@ export function AuthGate({ children }: AuthGateProps) {
requireAuth,
openSettingsModal,
openAccountModal,
setCurrentUser: setUser,
logout: logoutCurrentSession,
musicVolume: settings.musicVolume,
setMusicVolume: settings.setMusicVolume,
@@ -554,6 +612,7 @@ export function AuthGate({ children }: AuthGateProps) {
<AccountModal
user={readyUser}
isOpen={showSettingsModal}
entryMode={settingsEntryMode}
initialSection={initialSettingsSection}
platformTheme={settings.platformTheme}
riskBlocks={riskBlocks}
@@ -565,7 +624,7 @@ export function AuthGate({ children }: AuthGateProps) {
isHydratingSettings={settings.isHydratingSettings}
isPersistingSettings={settings.isPersistingSettings}
settingsError={settings.settingsError}
onClose={() => setShowSettingsModal(false)}
onClose={closeSettingsModal}
onPlatformThemeChange={settings.setPlatformTheme}
onLogout={logoutCurrentSession}
onRefreshRiskBlocks={async () => {
@@ -711,10 +770,12 @@ export function AuthGate({ children }: AuthGateProps) {
setLoggingIn(true);
setError('');
try {
const nextUser = await loginWithPhoneCode(phone, code);
const response = await loginWithPhoneCode(phone, code);
setStoredLastLoginPhone(phone);
setLoginCaptchaChallenge(null);
activateReadyUser(nextUser);
setShowRegistrationInviteModal(response.created);
setRegistrationInviteError('');
activateReadyUser(response.user);
} catch (loginError) {
setError(
loginError instanceof Error
@@ -775,6 +836,30 @@ export function AuthGate({ children }: AuthGateProps) {
}
}}
/>
<RegistrationInviteModal
isOpen={showRegistrationInviteModal}
platformTheme={settings.platformTheme}
initialInviteCode={pendingInviteCode}
submitting={submittingRegistrationInvite}
error={registrationInviteError}
onClose={closeRegistrationInviteModal}
onSubmit={async (inviteCode) => {
setSubmittingRegistrationInvite(true);
setRegistrationInviteError('');
try {
await redeemRegistrationInviteCode(inviteCode);
closeRegistrationInviteModal();
} catch (inviteError) {
setRegistrationInviteError(
inviteError instanceof Error
? inviteError.message
: '填写邀请码失败,请稍后再试。',
);
} finally {
setSubmittingRegistrationInvite(false);
}
}}
/>
</div>
{children}
</div>
+1
View File
@@ -17,6 +17,7 @@ type AuthUiContextValue = {
requireAuth: (action: () => void) => void;
openSettingsModal: (section?: PlatformSettingsSection) => void;
openAccountModal: () => void;
setCurrentUser: (user: AuthUser) => void;
logout: () => Promise<void>;
musicVolume: number;
setMusicVolume: (value: number) => void;
+1 -1
View File
@@ -62,7 +62,7 @@ export function BindPhoneScreen({
<div className="platform-auth-card grid w-full max-w-4xl overflow-hidden rounded-[28px] md:grid-cols-[1.05fr_0.95fr]">
<div className="border-b border-[var(--platform-subpanel-border)] bg-[linear-gradient(135deg,rgba(255,79,139,0.18),rgba(255,155,120,0.14))] px-6 py-8 md:border-b-0 md:border-r md:px-10 md:py-12">
<div className="selection-hero-brand selection-hero-brand--left">
<div className="selection-hero-brand__title">叙世</div>
<div className="selection-hero-brand__title">百梦</div>
<div className="selection-hero-brand__subtitle">视觉叙事 RPG</div>
</div>
<p className="mt-8 text-[11px] font-semibold tracking-[0.32em] text-[var(--platform-cool-text)]">
+12 -8
View File
@@ -196,9 +196,11 @@ export function LoginScreen({
/>
) : (
<div className="flex flex-col gap-5 px-5 py-5">
{phoneLoginEnabled && passwordLoginEnabled ? (
{phoneLoginEnabled ? (
<div
className="grid grid-cols-2 gap-2"
className={`grid gap-2 ${
passwordLoginEnabled ? 'grid-cols-2' : 'grid-cols-1'
}`}
role="tablist"
aria-label="登录方式"
>
@@ -208,12 +210,14 @@ export function LoginScreen({
>
短信登录
</LoginTabButton>
<LoginTabButton
active={activeLoginTab === 'password'}
onClick={() => setActiveLoginTab('password')}
>
密码登录
</LoginTabButton>
{passwordLoginEnabled ? (
<LoginTabButton
active={activeLoginTab === 'password'}
onClick={() => setActiveLoginTab('password')}
>
密码登录
</LoginTabButton>
) : null}
</div>
) : null}
@@ -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>

Some files were not shown because too many files have changed in this diff Show More