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
+9 -2
View File
@@ -1,4 +1,11 @@
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react';
import {
lazy,
Suspense,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { useAuthUi } from './components/auth/AuthUiContext';
import { PlatformEntryFlowShell } from './components/platform-entry/PlatformEntryFlowShell';
@@ -133,7 +140,7 @@ export default function App() {
return (
<div
className={`platform-ui-shell platform-theme ${platformThemeClass} flex h-screen max-h-screen flex-col overflow-hidden bg-[image:var(--platform-body-fill)] p-2 font-sans text-[var(--platform-text-strong)] sm:p-4`}
className={`platform-ui-shell platform-viewport-shell platform-theme ${platformThemeClass} flex flex-col overflow-hidden bg-[image:var(--platform-body-fill)] p-2 font-sans text-[var(--platform-text-strong)] sm:p-4`}
>
<PlatformEntryFlowShell
selectionStage={selectionStage}
+61
View File
@@ -0,0 +1,61 @@
import { useCallback, useRef, useState } from 'react';
import type {
Match3DClickItemRequest,
Match3DRunSnapshot,
} from '../packages/shared/src/contracts/match3dRuntime';
import { Match3DRuntimeShell } from './components/match3d-runtime';
import {
confirmLocalMatch3DClick,
resolveLocalMatch3DTimer,
startLocalMatch3DRun,
} from './services/match3d-runtime';
function buildInitialRun() {
return startLocalMatch3DRun(12);
}
export default function Match3DPlaygroundApp() {
const [run, setRun] = useState<Match3DRunSnapshot>(buildInitialRun);
const authorityRunRef = useRef(run);
const syncRun = useCallback((nextRun: Match3DRunSnapshot) => {
setRun(nextRun);
}, []);
const handleClickItem = useCallback(async (payload: Match3DClickItemRequest) => {
const result = await confirmLocalMatch3DClick(authorityRunRef.current, payload);
authorityRunRef.current = result.run;
setRun(result.run);
return result;
}, []);
const handleRestart = useCallback(() => {
const nextRun = buildInitialRun();
authorityRunRef.current = nextRun;
setRun(nextRun);
}, []);
const handleExit = useCallback(() => {
window.location.assign('/');
}, []);
const handleTimeExpired = useCallback(() => {
const nextRun = resolveLocalMatch3DTimer(authorityRunRef.current);
authorityRunRef.current = nextRun;
setRun(nextRun);
}, []);
return (
<Match3DRuntimeShell
run={run}
onBack={handleExit}
onRestart={handleRestart}
onOptimisticRunChange={syncRun}
onClickItem={handleClickItem}
onTimeExpired={handleTimeExpired}
error={null}
isBusy={false}
/>
);
}
+27 -8
View File
@@ -7,8 +7,10 @@ import type {
import type { PuzzleWorkSummary } from '../packages/shared/src/contracts/puzzleWorkSummary';
import { PuzzleRuntimeShell } from './components/puzzle-runtime/PuzzleRuntimeShell';
import {
applyLocalPuzzleFreezeTime,
advanceLocalPuzzleLevel,
dragLocalPuzzlePiece,
setLocalPuzzlePaused,
startLocalPuzzleRun,
swapLocalPuzzlePieces,
} from './services/puzzle-runtime/puzzleLocalRuntime';
@@ -16,7 +18,7 @@ import {
const PLACEHOLDER_PUZZLE_IMAGE =
'data:image/svg+xml;utf8,' +
encodeURIComponent(`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 960">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#fef3c7" />
@@ -28,13 +30,13 @@ const PLACEHOLDER_PUZZLE_IMAGE =
<stop offset="1" stop-color="#ffffff" stop-opacity="0" />
</radialGradient>
</defs>
<rect width="960" height="960" fill="url(#sky)" />
<circle cx="312" cy="282" r="210" fill="url(#glow)" />
<path d="M0 680 C170 610 278 724 424 650 C574 574 704 612 960 512 V960 H0 Z" fill="#1e1b4b" opacity="0.9" />
<path d="M0 766 C178 710 320 794 492 732 C642 678 780 708 960 652 V960 H0 Z" fill="#111827" opacity="0.78" />
<path d="M160 356 C238 298 326 304 388 376 C456 456 550 436 626 374 C710 306 824 330 882 410" fill="none" stroke="#fff7ed" stroke-width="18" stroke-linecap="round" opacity="0.72" />
<path d="M204 502 h552" stroke="#ffffff" stroke-width="16" stroke-linecap="round" opacity="0.3" />
<path d="M268 566 h424" stroke="#ffffff" stroke-width="12" stroke-linecap="round" opacity="0.22" />
<rect width="1024" height="1024" fill="url(#sky)" />
<circle cx="378" cy="286" r="230" fill="url(#glow)" />
<path d="M0 690 C168 626 296 724 446 666 C596 606 744 628 1024 536 V1024 H0 Z" fill="#1e1b4b" opacity="0.9" />
<path d="M0 822 C190 760 328 850 516 790 C672 738 824 754 1024 704 V1024 H0 Z" fill="#111827" opacity="0.78" />
<path d="M138 328 C226 266 340 266 420 338 C518 426 632 390 740 326 C834 272 920 300 960 362" fill="none" stroke="#fff7ed" stroke-width="18" stroke-linecap="round" opacity="0.72" />
<path d="M190 548 h640" stroke="#ffffff" stroke-width="16" stroke-linecap="round" opacity="0.3" />
<path d="M268 628 h488" stroke="#ffffff" stroke-width="12" stroke-linecap="round" opacity="0.22" />
</svg>`);
function buildPlaceholderPuzzleWork(): PuzzleWorkSummary {
@@ -53,6 +55,7 @@ function buildPlaceholderPuzzleWork(): PuzzleWorkSummary {
updatedAt: new Date(0).toISOString(),
publishedAt: new Date(0).toISOString(),
playCount: 0,
likeCount: 0,
publishReady: true,
};
}
@@ -77,6 +80,20 @@ export default function PuzzlePlaygroundApp() {
setRun((currentRun) => advanceLocalPuzzleLevel(currentRun));
};
const handlePauseChange = async (paused: boolean) => {
setRun((currentRun) => setLocalPuzzlePaused(currentRun, paused));
};
const handleUseProp = async (
propKind: 'hint' | 'reference' | 'freezeTime',
) => {
setRun((currentRun) =>
propKind === 'freezeTime'
? applyLocalPuzzleFreezeTime(currentRun)
: setLocalPuzzlePaused(currentRun, propKind === 'reference'),
);
};
return (
<PuzzleRuntimeShell
run={run}
@@ -86,6 +103,8 @@ export default function PuzzlePlaygroundApp() {
onSwapPieces={handleSwapPieces}
onDragPiece={handleDragPiece}
onAdvanceNextLevel={handleAdvanceNextLevel}
onPauseChange={handlePauseChange}
onUseProp={handleUseProp}
/>
);
}
+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}`}
>

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