@@ -57,6 +57,11 @@ test('adventure panel treats negative affinity updates as relationship change sy
|
||||
acknowledgeQuestCompletion: () => undefined,
|
||||
claimQuestReward: () => null,
|
||||
}}
|
||||
npcChatQuestOfferUi={{
|
||||
replacePendingOffer: async () => false,
|
||||
abandonPendingOffer: () => false,
|
||||
acceptPendingOffer: () => null,
|
||||
}}
|
||||
goalStack={{
|
||||
northStarGoal: null,
|
||||
activeGoal: null,
|
||||
|
||||
@@ -75,6 +75,11 @@ function renderPanel(
|
||||
acknowledgeQuestCompletion: () => undefined,
|
||||
claimQuestReward: () => null,
|
||||
}}
|
||||
npcChatQuestOfferUi={{
|
||||
replacePendingOffer: async () => false,
|
||||
abandonPendingOffer: () => false,
|
||||
acceptPendingOffer: () => null,
|
||||
}}
|
||||
goalStack={{
|
||||
northStarGoal: null,
|
||||
activeGoal: null,
|
||||
@@ -174,3 +179,67 @@ test('adventure panel shows npc chat custom input and exit button in chat mode',
|
||||
expect(html).toContain('发送');
|
||||
expect(html).not.toContain('换一换');
|
||||
});
|
||||
|
||||
test('adventure panel hides custom input and shows quest offer actions during npc quest offer mode', () => {
|
||||
const viewOption = createOption('npc_chat_quest_offer_view', '查看任务');
|
||||
viewOption.runtimePayload = {
|
||||
npcChatQuestOfferAction: 'view',
|
||||
};
|
||||
const replaceOption = createOption('npc_chat_quest_offer_replace', '更换任务');
|
||||
replaceOption.runtimePayload = {
|
||||
npcChatQuestOfferAction: 'replace',
|
||||
};
|
||||
const abandonOption = createOption('npc_chat_quest_offer_abandon', '放弃任务');
|
||||
abandonOption.runtimePayload = {
|
||||
npcChatQuestOfferAction: 'abandon',
|
||||
};
|
||||
const currentStory: StoryMoment = {
|
||||
text: '柳无声把真正的委托说了出来。',
|
||||
displayMode: 'dialogue',
|
||||
dialogue: [
|
||||
{ speaker: 'player', text: '你像是还有别的话想说。' },
|
||||
{ speaker: 'npc', speakerName: '柳无声', text: '确实有一件事想正式托付给你。' },
|
||||
],
|
||||
options: [viewOption, replaceOption, abandonOption],
|
||||
npcChatState: {
|
||||
npcId: 'npc-liu',
|
||||
npcName: '柳无声',
|
||||
turnCount: 2,
|
||||
customInputPlaceholder: '输入你想对 TA 说的话',
|
||||
pendingQuestOffer: {
|
||||
quest: {
|
||||
id: 'quest-liu-1',
|
||||
issuerNpcId: 'npc-liu',
|
||||
issuerNpcName: '柳无声',
|
||||
sceneId: 'scene-bamboo',
|
||||
title: '竹林密信',
|
||||
description: '替柳无声查清竹林中的密信来源。',
|
||||
summary: '去竹林查清密信来源。',
|
||||
objective: {
|
||||
kind: 'inspect_treasure',
|
||||
requiredCount: 1,
|
||||
},
|
||||
progress: 0,
|
||||
status: 'active',
|
||||
reward: {
|
||||
affinityBonus: 5,
|
||||
currency: 10,
|
||||
items: [],
|
||||
},
|
||||
rewardText: '完成后可获得报酬。',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const html = renderPanel(currentStory, [viewOption, replaceOption, abandonOption], {
|
||||
onSubmitNpcChatInput: () => true,
|
||||
onExitNpcChat: () => true,
|
||||
});
|
||||
|
||||
expect(html).toContain('查看任务');
|
||||
expect(html).toContain('更换任务');
|
||||
expect(html).toContain('放弃任务');
|
||||
expect(html).not.toContain('发送');
|
||||
expect(html).not.toContain('输入你想对 TA 说的话');
|
||||
});
|
||||
|
||||
@@ -28,7 +28,11 @@ import { resolveInventoryItemUseEffect } from '../data/inventoryEffects';
|
||||
import { isQuestReadyToClaim } from '../data/questFlow';
|
||||
import { getScenePresetById } from '../data/scenePresets';
|
||||
import { getOptionImpactSummary } from '../hooks/combatStoryUtils';
|
||||
import type { BattleRewardUi, QuestFlowUi } from '../hooks/useStoryGeneration';
|
||||
import type {
|
||||
BattleRewardUi,
|
||||
NpcChatQuestOfferUi,
|
||||
QuestFlowUi,
|
||||
} from '../hooks/useStoryGeneration';
|
||||
import type {
|
||||
ChapterState,
|
||||
Character,
|
||||
@@ -70,6 +74,7 @@ interface AdventurePanelProps {
|
||||
worldType: WorldType | null;
|
||||
quests: QuestLogEntry[];
|
||||
questUi: QuestFlowUi;
|
||||
npcChatQuestOfferUi: NpcChatQuestOfferUi;
|
||||
goalStack: GoalStackState;
|
||||
goalPulse: GoalPulseEvent | null;
|
||||
onDismissGoalPulse: () => void;
|
||||
@@ -625,6 +630,7 @@ export function AdventurePanel({
|
||||
worldType,
|
||||
quests,
|
||||
questUi,
|
||||
npcChatQuestOfferUi,
|
||||
goalStack,
|
||||
goalPulse,
|
||||
onDismissGoalPulse,
|
||||
@@ -646,6 +652,8 @@ export function AdventurePanel({
|
||||
const dialogueTurns = currentStory.dialogue ?? [];
|
||||
const npcChatState = currentStory.npcChatState ?? null;
|
||||
const isNpcChatMode = Boolean(npcChatState);
|
||||
const pendingNpcQuestOffer = npcChatState?.pendingQuestOffer?.quest ?? null;
|
||||
const isNpcQuestOfferMode = Boolean(pendingNpcQuestOffer);
|
||||
const isStoryStreaming = Boolean(currentStory.streaming);
|
||||
const shouldHideChoiceUi = hideOptions;
|
||||
const storyScrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -689,8 +697,10 @@ export function AdventurePanel({
|
||||
[quests],
|
||||
);
|
||||
const selectedQuest = useMemo(
|
||||
() => quests.find((quest) => quest.id === selectedQuestId) ?? null,
|
||||
[quests, selectedQuestId],
|
||||
() =>
|
||||
quests.find((quest) => quest.id === selectedQuestId) ??
|
||||
(pendingNpcQuestOffer?.id === selectedQuestId ? pendingNpcQuestOffer : null),
|
||||
[pendingNpcQuestOffer, quests, selectedQuestId],
|
||||
);
|
||||
const rewardQuest = useMemo(
|
||||
() => quests.find((quest) => quest.id === rewardQuestId) ?? null,
|
||||
@@ -901,6 +911,27 @@ export function AdventurePanel({
|
||||
Boolean(selectedRewardItem);
|
||||
|
||||
const handleOptionChoice = (option: StoryOption) => {
|
||||
const pendingQuestAction =
|
||||
typeof option.runtimePayload?.npcChatQuestOfferAction === 'string'
|
||||
? option.runtimePayload.npcChatQuestOfferAction
|
||||
: null;
|
||||
if (pendingQuestAction && pendingNpcQuestOffer) {
|
||||
if (pendingQuestAction === 'view') {
|
||||
setSelectedQuestId(pendingNpcQuestOffer.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingQuestAction === 'replace') {
|
||||
void npcChatQuestOfferUi.replacePendingOffer();
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingQuestAction === 'abandon') {
|
||||
npcChatQuestOfferUi.abandonPendingOffer();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
option.interaction?.kind === 'npc' &&
|
||||
option.interaction.action === 'quest_accept'
|
||||
@@ -1179,7 +1210,7 @@ export function AdventurePanel({
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{isNpcChatMode ? (
|
||||
{isNpcChatMode && !isNpcQuestOfferMode ? (
|
||||
<div className="pixel-nine-slice pixel-panel mt-0.5 border border-white/10 bg-black/25 p-1.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<input
|
||||
@@ -1263,6 +1294,13 @@ export function AdventurePanel({
|
||||
selectedRewardEquipSlot={selectedRewardEquipSlot}
|
||||
selectedQuestSceneName={selectedQuestSceneName}
|
||||
getQuestStatusLabel={getQuestStatusLabel}
|
||||
pendingNpcQuestOffer={pendingNpcQuestOffer}
|
||||
onAcceptPendingNpcQuestOffer={() => {
|
||||
const acceptedQuestId = npcChatQuestOfferUi.acceptPendingOffer();
|
||||
if (!acceptedQuestId) return null;
|
||||
setSelectedQuestId(null);
|
||||
return acceptedQuestId;
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
77
src/components/CustomWorldCoverArtwork.tsx
Normal file
77
src/components/CustomWorldCoverArtwork.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import type { CustomWorldCoverRenderMode } from '../services/customWorldCover';
|
||||
|
||||
const COVER_PORTRAIT_CLASS_NAMES = [
|
||||
'h-[54%] w-[24%] translate-y-[8%]',
|
||||
'h-[68%] w-[30%]',
|
||||
'h-[56%] w-[24%] translate-y-[10%]',
|
||||
] as const;
|
||||
|
||||
type CustomWorldCoverArtworkProps = {
|
||||
imageSrc?: string | null;
|
||||
title: string;
|
||||
fallbackLabel: string;
|
||||
renderMode?: CustomWorldCoverRenderMode;
|
||||
characterImageSrcs?: string[];
|
||||
className?: string;
|
||||
overlay?: ReactNode;
|
||||
};
|
||||
|
||||
export function CustomWorldCoverArtwork({
|
||||
imageSrc,
|
||||
title,
|
||||
fallbackLabel,
|
||||
renderMode = 'image',
|
||||
characterImageSrcs = [],
|
||||
className = '',
|
||||
overlay,
|
||||
}: CustomWorldCoverArtworkProps) {
|
||||
const coverCharacterImageSrcs = characterImageSrcs.slice(0, 3);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative overflow-hidden bg-[radial-gradient(circle_at_top,rgba(255,244,214,0.3),transparent_38%),linear-gradient(180deg,rgba(34,40,55,0.92),rgba(10,12,18,0.96))] ${className}`}
|
||||
>
|
||||
{imageSrc ? (
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={title}
|
||||
loading="lazy"
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,14,0.04),rgba(8,10,14,0.26)_46%,rgba(8,10,14,0.82)_100%)]" />
|
||||
{!imageSrc ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center px-4 text-center text-sm font-semibold tracking-[0.18em] text-zinc-300">
|
||||
{fallbackLabel}
|
||||
</div>
|
||||
) : null}
|
||||
{renderMode === 'scene_with_roles' && coverCharacterImageSrcs.length > 0 ? (
|
||||
<>
|
||||
<div className="absolute inset-x-0 bottom-0 h-[42%] bg-[linear-gradient(180deg,rgba(8,10,14,0)_0%,rgba(8,10,14,0.88)_100%)]" />
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-center gap-2 px-3 pb-2 sm:pb-3">
|
||||
{coverCharacterImageSrcs.map((characterImageSrc, index) => (
|
||||
<div
|
||||
key={`${title}-cover-character-${index}-${characterImageSrc}`}
|
||||
className={`overflow-hidden rounded-[1rem] border border-white/16 bg-[linear-gradient(180deg,rgba(255,255,255,0.14),rgba(255,255,255,0.04))] shadow-[0_12px_28px_rgba(0,0,0,0.4)] ${COVER_PORTRAIT_CLASS_NAMES[index] ?? COVER_PORTRAIT_CLASS_NAMES[1]}`}
|
||||
>
|
||||
<img
|
||||
src={characterImageSrc}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover object-top"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{overlay ? (
|
||||
<div className="pointer-events-none absolute inset-0">{overlay}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CustomWorldCoverArtwork;
|
||||
@@ -18,9 +18,11 @@ import {
|
||||
resolveCustomWorldLandmarkImageMap,
|
||||
} from '../data/customWorldVisuals';
|
||||
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
|
||||
import { resolveCustomWorldCoverPresentation } from '../services/customWorldCover';
|
||||
import { normalizeCustomWorldCreatorIntent } from '../services/customWorldCreatorIntent';
|
||||
import { AnimationState, Character, CustomWorldProfile } from '../types';
|
||||
import { CharacterAnimator } from './CharacterAnimator';
|
||||
import { CustomWorldCoverArtwork } from './CustomWorldCoverArtwork';
|
||||
import type { CustomWorldEditorTarget } from './CustomWorldEntityEditorModal';
|
||||
import { CustomWorldNpcPortrait } from './CustomWorldNpcVisualEditor';
|
||||
|
||||
@@ -941,6 +943,10 @@ export function CustomWorldEntityCatalog({
|
||||
1 +
|
||||
(pendingGeneratedEntity?.kind === 'landmark' ? 1 : 0),
|
||||
} satisfies Record<ResultTab, number>;
|
||||
const coverPresentation = useMemo(
|
||||
() => resolveCustomWorldCoverPresentation(profile),
|
||||
[profile],
|
||||
);
|
||||
|
||||
const bulkDeleteTab: BulkDeleteTab | null =
|
||||
activeTab === 'story' || activeTab === 'landmarks' ? activeTab : null;
|
||||
@@ -1124,6 +1130,40 @@ export function CustomWorldEntityCatalog({
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="作品封面"
|
||||
badge={
|
||||
<span className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">
|
||||
{coverPresentation.sourceType === 'uploaded'
|
||||
? '上传封面'
|
||||
: coverPresentation.sourceType === 'generated'
|
||||
? 'AI封面'
|
||||
: '默认封面'}
|
||||
</span>
|
||||
}
|
||||
actions={
|
||||
!readOnly ? (
|
||||
<SmallButton
|
||||
onClick={() => onEditTarget({ kind: 'cover' })}
|
||||
tone="sky"
|
||||
>
|
||||
编辑
|
||||
</SmallButton>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<CustomWorldCoverArtwork
|
||||
imageSrc={coverPresentation.imageSrc}
|
||||
title={profile.name}
|
||||
fallbackLabel={profile.name.slice(0, 4) || '封面'}
|
||||
renderMode={coverPresentation.renderMode}
|
||||
characterImageSrcs={coverPresentation.characterImageSrcs}
|
||||
className="aspect-[16/9] rounded-[1.4rem] border border-[var(--platform-subpanel-border)]"
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="世界概述"
|
||||
actions={
|
||||
|
||||
@@ -210,7 +210,7 @@ function LandmarkEditorFlowHarness() {
|
||||
}
|
||||
|
||||
function CampEditorFlowHarness() {
|
||||
const [profile, setProfile] = useState({
|
||||
const [profile, setProfile] = useState<CustomWorldProfile>({
|
||||
...createProfileWithLandmark(),
|
||||
camp: {
|
||||
name: '潮灯居',
|
||||
|
||||
@@ -24,7 +24,17 @@ import {
|
||||
generateCustomWorldSceneImage,
|
||||
generateCustomWorldSceneNpc,
|
||||
} from '../services/aiService';
|
||||
import {
|
||||
generateCustomWorldCoverImage,
|
||||
uploadCustomWorldCoverImage,
|
||||
type CustomWorldCoverAssetResult,
|
||||
} from '../services/customWorldCoverAssetService';
|
||||
import { buildSkillActionPrompt } from '../prompts/customWorldEntityActionPrompts';
|
||||
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
|
||||
import {
|
||||
buildDefaultCustomWorldCoverProfile,
|
||||
resolveCustomWorldCoverPresentation,
|
||||
} from '../services/customWorldCover';
|
||||
import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
@@ -33,6 +43,7 @@ import {
|
||||
CustomWorldNpc,
|
||||
CustomWorldPlayableNpc,
|
||||
CustomWorldProfile,
|
||||
type CustomWorldCoverProfile,
|
||||
type CustomWorldRoleInitialItem,
|
||||
type CustomWorldRoleRelation,
|
||||
type CustomWorldRoleSkill,
|
||||
@@ -47,6 +58,7 @@ import {
|
||||
} from './asset-studio/characterAssetWorkflowPersistence';
|
||||
import { useAuthUi } from './auth/AuthUiContext';
|
||||
import { CharacterAnimator } from './CharacterAnimator';
|
||||
import { CustomWorldCoverArtwork } from './CustomWorldCoverArtwork';
|
||||
import { buildDefaultCustomWorldNpcVisual } from './customWorldNpcVisualDefaults';
|
||||
import {
|
||||
CustomWorldNpcPortrait,
|
||||
@@ -57,6 +69,7 @@ import { PixelIcon } from './PixelIcon';
|
||||
|
||||
export type CustomWorldEditorTarget =
|
||||
| { kind: 'world' }
|
||||
| { kind: 'cover' }
|
||||
| { kind: 'camp' }
|
||||
| { kind: 'playable'; mode: 'create' }
|
||||
| { kind: 'playable'; mode: 'edit'; id: string }
|
||||
@@ -273,24 +286,6 @@ function inferSkillActionTemplateId(skill: Pick<CustomWorldRoleSkill, 'name' | '
|
||||
return 'attack_slash';
|
||||
}
|
||||
|
||||
function buildSkillActionPrompt(params: {
|
||||
role: Pick<CustomWorldPlayableNpc | CustomWorldNpc, 'name' | 'title' | 'role' | 'description' | 'backstory' | 'personality' | 'motivation'>;
|
||||
skill: Pick<CustomWorldRoleSkill, 'name' | 'summary'>;
|
||||
}) {
|
||||
const { role, skill } = params;
|
||||
return [
|
||||
`${role.name},${role.title || role.role}。`,
|
||||
`技能名称:${skill.name}。`,
|
||||
skill.summary ? `技能表现:${skill.summary}。` : '',
|
||||
role.description ? `角色气质:${role.description}。` : '',
|
||||
role.personality ? `性格补充:${role.personality}。` : '',
|
||||
role.motivation ? `动作目标:${role.motivation}。` : '',
|
||||
'横版 RPG 角色技能动作,角色轮廓稳定,动作起手明确,过程连贯,收招干净,镜头稳定。',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function createRoleRelationDraft(seedLabel: string, index: number): CustomWorldRoleRelation {
|
||||
return {
|
||||
id: createEntryId('relation', seedLabel, Date.now() + index),
|
||||
@@ -1687,6 +1682,408 @@ function SceneImageGenerationModal({
|
||||
);
|
||||
}
|
||||
|
||||
const FIXED_COVER_IMAGE_SIZE = '1600*900';
|
||||
|
||||
function buildGeneratedCoverProfile(
|
||||
result: CustomWorldCoverAssetResult,
|
||||
): CustomWorldCoverProfile {
|
||||
return {
|
||||
sourceType: result.sourceType,
|
||||
imageSrc: result.imageSrc,
|
||||
characterRoleIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
function CoverImageGenerationModal({
|
||||
profile,
|
||||
onApply,
|
||||
onClose,
|
||||
}: {
|
||||
profile: CustomWorldProfile;
|
||||
onApply: (result: CustomWorldCoverAssetResult) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const initialPresentation = useMemo(
|
||||
() => resolveCustomWorldCoverPresentation(profile),
|
||||
[profile],
|
||||
);
|
||||
const [userPrompt, setUserPrompt] = useDraft(profile.summary || profile.name);
|
||||
const [referenceImageSrc, setReferenceImageSrc] = useState('');
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [latestResult, setLatestResult] =
|
||||
useState<CustomWorldCoverAssetResult | null>(null);
|
||||
const [isExitConfirmOpen, setIsExitConfirmOpen] = useState(false);
|
||||
|
||||
const previewImageSrc = latestResult?.imageSrc || initialPresentation.imageSrc;
|
||||
|
||||
const handleReferenceImageChange = async (
|
||||
event: ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.currentTarget.value = '';
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const dataUrl = await readImageFileAsDataUrl(file);
|
||||
setReferenceImageSrc(dataUrl);
|
||||
setError(null);
|
||||
} catch (uploadError) {
|
||||
setError(
|
||||
uploadError instanceof Error
|
||||
? uploadError.message
|
||||
: '参考图读取失败,请重试。',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRequestClose = () => {
|
||||
if (isGenerating) {
|
||||
return;
|
||||
}
|
||||
if (latestResult) {
|
||||
setIsExitConfirmOpen(true);
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!userPrompt.trim()) {
|
||||
setError('请先补一句你想要的封面氛围。');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await generateCustomWorldCoverImage({
|
||||
profile,
|
||||
userPrompt,
|
||||
referenceImageSrc,
|
||||
characterRoleIds:
|
||||
profile.cover?.sourceType === 'default'
|
||||
? profile.cover.characterRoleIds
|
||||
: buildDefaultCustomWorldCoverProfile(profile).characterRoleIds,
|
||||
size: FIXED_COVER_IMAGE_SIZE,
|
||||
});
|
||||
setLatestResult(result);
|
||||
} catch (generationError) {
|
||||
setError(
|
||||
generationError instanceof Error
|
||||
? generationError.message
|
||||
: '作品封面生成失败,请稍后重试。',
|
||||
);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!latestResult || isGenerating) {
|
||||
return;
|
||||
}
|
||||
onApply(latestResult);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalShell
|
||||
title="AI 生成作品封面"
|
||||
onClose={handleRequestClose}
|
||||
panelClassName="sm:max-w-5xl"
|
||||
overlayClassName="z-[99]"
|
||||
disableClose={isGenerating}
|
||||
>
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.1fr)_minmax(20rem,0.9fr)]">
|
||||
<div className="space-y-4">
|
||||
<Field label="封面氛围">
|
||||
<TextArea
|
||||
value={userPrompt}
|
||||
onChange={(value) => setUserPrompt(value)}
|
||||
rows={7}
|
||||
placeholder="例如:风雪中的山门前景,三位主角立在残灯与旗帜之间,整体像一张正式作品封面。"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="参考图(可选)">
|
||||
<div className="space-y-3">
|
||||
<label className="block rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
onChange={(event) => {
|
||||
void handleReferenceImageChange(event);
|
||||
}}
|
||||
className="w-full text-xs text-zinc-300 file:mr-3 file:rounded-lg file:border-0 file:bg-sky-500 file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-black"
|
||||
/>
|
||||
</label>
|
||||
{referenceImageSrc ? (
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-white/8 bg-black/20 p-3">
|
||||
<div className="h-16 w-24 overflow-hidden rounded-xl border border-white/10 bg-black/30">
|
||||
<img
|
||||
src={referenceImageSrc}
|
||||
alt="封面参考图"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-xs leading-5 text-zinc-400">
|
||||
已载入封面参考图
|
||||
</div>
|
||||
<ActionButton
|
||||
label="移除"
|
||||
onClick={() => setReferenceImageSrc('')}
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/18 p-3">
|
||||
<CustomWorldCoverArtwork
|
||||
imageSrc={previewImageSrc}
|
||||
title={profile.name}
|
||||
fallbackLabel={profile.name.slice(0, 4) || '封面'}
|
||||
renderMode={
|
||||
latestResult ? 'image' : initialPresentation.renderMode
|
||||
}
|
||||
characterImageSrcs={
|
||||
latestResult ? [] : initialPresentation.characterImageSrcs
|
||||
}
|
||||
className="aspect-[16/9] rounded-2xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{latestResult ? (
|
||||
<div className="rounded-2xl border border-emerald-300/18 bg-emerald-500/10 px-4 py-3 text-sm leading-6 text-emerald-50">
|
||||
已生成完毕,保存后将替换当前作品封面。
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-2xl border border-rose-400/18 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||||
<ActionButton
|
||||
label="保存"
|
||||
onClick={handleSave}
|
||||
disabled={!latestResult || isGenerating}
|
||||
/>
|
||||
<ActionButton
|
||||
label={
|
||||
isGenerating
|
||||
? '正在生成...'
|
||||
: latestResult
|
||||
? '重新生成'
|
||||
: '开始生成'
|
||||
}
|
||||
onClick={() => {
|
||||
void handleGenerate();
|
||||
}}
|
||||
tone="sky"
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalShell>
|
||||
|
||||
{isExitConfirmOpen ? (
|
||||
<PortalCompactDialogShell
|
||||
title="确认退出"
|
||||
onClose={() => setIsExitConfirmOpen(false)}
|
||||
overlayClassName="z-[140]"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-amber-300/16 bg-amber-500/10 px-4 py-4 text-sm leading-6 text-amber-50">
|
||||
当前生成结果还没有保存,确认退出吗?
|
||||
</div>
|
||||
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||||
<ActionButton
|
||||
label="继续编辑"
|
||||
onClick={() => setIsExitConfirmOpen(false)}
|
||||
/>
|
||||
<ActionButton
|
||||
label="确认退出"
|
||||
onClick={() => {
|
||||
setIsExitConfirmOpen(false);
|
||||
onClose();
|
||||
}}
|
||||
tone="sky"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PortalCompactDialogShell>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function WorldCoverEditor({
|
||||
profile,
|
||||
onSaveProfile,
|
||||
onClose,
|
||||
}: {
|
||||
profile: CustomWorldProfile;
|
||||
onSaveProfile: (profile: CustomWorldProfile) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [draftCover, setDraftCover] = useDraft(
|
||||
profile.cover ?? buildDefaultCustomWorldCoverProfile(profile),
|
||||
);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const previewProfile = useMemo(
|
||||
() => ({
|
||||
...profile,
|
||||
cover: draftCover,
|
||||
}),
|
||||
[draftCover, profile],
|
||||
);
|
||||
const previewPresentation = useMemo(
|
||||
() => resolveCustomWorldCoverPresentation(previewProfile),
|
||||
[previewProfile],
|
||||
);
|
||||
|
||||
const handleUploadCover = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.currentTarget.value = '';
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
setUploadError(null);
|
||||
try {
|
||||
const imageDataUrl = await readImageFileAsDataUrl(file);
|
||||
const result = await uploadCustomWorldCoverImage({
|
||||
profileId: profile.id,
|
||||
worldName: profile.name,
|
||||
imageDataUrl,
|
||||
});
|
||||
setDraftCover(buildGeneratedCoverProfile(result));
|
||||
} catch (uploadErrorValue) {
|
||||
setUploadError(
|
||||
uploadErrorValue instanceof Error
|
||||
? uploadErrorValue.message
|
||||
: '上传作品封面失败,请稍后重试。',
|
||||
);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalShell title="编辑作品封面" onClose={onClose}>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/18 p-3">
|
||||
<CustomWorldCoverArtwork
|
||||
imageSrc={previewPresentation.imageSrc}
|
||||
title={profile.name}
|
||||
fallbackLabel={profile.name.slice(0, 4) || '封面'}
|
||||
renderMode={previewPresentation.renderMode}
|
||||
characterImageSrcs={previewPresentation.characterImageSrcs}
|
||||
className="aspect-[16/9] rounded-2xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<span className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[11px] text-zinc-200">
|
||||
{draftCover.sourceType === 'uploaded'
|
||||
? '当前为上传封面'
|
||||
: draftCover.sourceType === 'generated'
|
||||
? '当前为 AI 封面'
|
||||
: '当前为默认封面'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<label className="block rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4">
|
||||
<div className="mb-3 text-[11px] font-bold tracking-[0.14em] text-zinc-300">
|
||||
上传封面
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
onChange={(event) => {
|
||||
void handleUploadCover(event);
|
||||
}}
|
||||
className="w-full text-xs text-zinc-300 file:mr-3 file:rounded-lg file:border-0 file:bg-sky-500 file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-black"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<ActionButton
|
||||
label="AI 生成"
|
||||
onClick={() => setIsGenerating(true)}
|
||||
tone="sky"
|
||||
/>
|
||||
<ActionButton
|
||||
label="重置为默认"
|
||||
onClick={() =>
|
||||
setDraftCover(buildDefaultCustomWorldCoverProfile(profile))
|
||||
}
|
||||
disabled={draftCover.sourceType === 'default'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{uploadError ? (
|
||||
<div className="rounded-2xl border border-rose-400/18 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
|
||||
{uploadError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<SaveBar
|
||||
onClose={onClose}
|
||||
onSave={() => {
|
||||
onSaveProfile({
|
||||
...profile,
|
||||
cover: draftCover,
|
||||
});
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</ModalShell>
|
||||
|
||||
{isGenerating ? (
|
||||
<CoverImageGenerationModal
|
||||
profile={previewProfile}
|
||||
onApply={(result) => {
|
||||
setDraftCover(buildGeneratedCoverProfile(result));
|
||||
setUploadError(null);
|
||||
setIsGenerating(false);
|
||||
}}
|
||||
onClose={() => setIsGenerating(false)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isUploading ? (
|
||||
<PortalCompactDialogShell
|
||||
title="上传封面中"
|
||||
onClose={() => {}}
|
||||
disableClose
|
||||
>
|
||||
<div className="rounded-2xl border border-sky-300/18 bg-sky-500/10 px-4 py-4 text-sm leading-6 text-sky-50">
|
||||
正在保存封面资源,请稍候。
|
||||
</div>
|
||||
</PortalCompactDialogShell>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SaveBar({
|
||||
onClose,
|
||||
onSave,
|
||||
@@ -4378,6 +4775,16 @@ function CustomWorldEntityEditorModal({
|
||||
);
|
||||
}
|
||||
|
||||
if (target.kind === 'cover') {
|
||||
return (
|
||||
<WorldCoverEditor
|
||||
profile={profile}
|
||||
onSaveProfile={onProfileChange}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (target.kind === 'camp') {
|
||||
return (
|
||||
<CampSceneEditor
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} from './asset-studio/characterAssetWorkflowPersistence';
|
||||
import { buildDefaultRolePromptBundle } from './asset-studio/customWorldRolePromptDefaults';
|
||||
import { buildProjectPixelStyleReferenceBoard } from './asset-studio/projectPixelStyleReference';
|
||||
import { useAuthUi } from './auth/AuthUiContext';
|
||||
import { CharacterAnimator } from './CharacterAnimator';
|
||||
|
||||
type EditableCustomWorldRole = {
|
||||
@@ -136,9 +137,15 @@ function ModalShell({
|
||||
disableClose?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const authUi = useAuthUi();
|
||||
const platformThemeClass =
|
||||
authUi?.platformTheme === 'dark'
|
||||
? 'platform-theme--dark'
|
||||
: 'platform-theme--light';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-end justify-center bg-black/78 p-0 backdrop-blur-sm sm:items-center sm:p-4"
|
||||
className={`platform-overlay platform-theme ${platformThemeClass} fixed inset-0 z-[100] flex items-end justify-center p-0 backdrop-blur-sm sm:items-center sm:p-4`}
|
||||
onClick={
|
||||
disableClose
|
||||
? undefined
|
||||
@@ -150,7 +157,7 @@ function ModalShell({
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="flex h-[92vh] w-full max-w-6xl flex-col overflow-hidden rounded-t-[1.75rem] border border-white/10 bg-[linear-gradient(180deg,rgba(19,24,39,0.98),rgba(8,10,17,0.96))] shadow-[0_24px_80px_rgba(0,0,0,0.6)] sm:h-auto sm:max-h-[min(92vh,58rem)] sm:rounded-[1.75rem]"
|
||||
className={`platform-modal-shell platform-role-studio platform-ui-shell platform-theme ${platformThemeClass} flex h-[92vh] w-full max-w-6xl flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.6)] sm:h-auto sm:max-h-[min(92vh,58rem)] sm:rounded-[1.75rem]`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 border-b border-white/10 px-4 py-4 sm:px-5">
|
||||
@@ -1146,7 +1153,7 @@ export function CustomWorldRoleAssetStudioModal({
|
||||
<div className="space-y-5">
|
||||
<Section title="角色形象">
|
||||
<div className="space-y-4">
|
||||
<div className="overflow-hidden rounded-3xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.16),transparent_48%),linear-gradient(180deg,rgba(19,24,39,0.96),rgba(8,10,17,0.92))]">
|
||||
<div className="platform-role-studio__preview overflow-hidden rounded-3xl">
|
||||
<div className="flex min-h-[18rem] items-center justify-center p-4 sm:min-h-[22rem]">
|
||||
{previewImageSrc ? (
|
||||
<img
|
||||
@@ -1249,8 +1256,8 @@ export function CustomWorldRoleAssetStudioModal({
|
||||
|
||||
<Section title="动作">
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-3xl border border-white/10 bg-[linear-gradient(180deg,rgba(19,24,39,0.96),rgba(8,10,17,0.92))] p-4">
|
||||
<div className="flex min-h-[28rem] items-center justify-center rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||
<div className="platform-role-studio__preview rounded-3xl p-4">
|
||||
<div className="platform-role-studio__stage flex min-h-[28rem] items-center justify-center rounded-2xl p-4">
|
||||
{previewCharacter &&
|
||||
hasGeneratedAnimation(workingRole, selectedAnimation) ? (
|
||||
<div
|
||||
@@ -1402,7 +1409,7 @@ export function CustomWorldRoleAssetStudioModal({
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<div className="sticky bottom-0 z-10 -mx-4 border-t border-white/10 bg-[linear-gradient(180deg,rgba(8,10,17,0.2)_0%,rgba(8,10,17,0.96)_28%)] px-4 pb-[calc(env(safe-area-inset-bottom,0px)+0.35rem)] pt-3 backdrop-blur sm:mx-0 sm:rounded-3xl sm:border sm:px-4">
|
||||
<div className="platform-role-studio__footer sticky bottom-0 z-10 -mx-4 px-4 pb-[calc(env(safe-area-inset-bottom,0px)+0.35rem)] pt-3 sm:mx-0 sm:rounded-3xl sm:border sm:border-[var(--platform-subpanel-border)] sm:px-4">
|
||||
<div className="space-y-3">
|
||||
{saveStatus ? (
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type CharacterChatUi,
|
||||
type GoalFlowUi,
|
||||
type InventoryFlowUi,
|
||||
type NpcChatQuestOfferUi,
|
||||
type QuestFlowUi,
|
||||
type StoryGenerationNpcUi,
|
||||
} from '../hooks/useStoryGeneration';
|
||||
@@ -53,6 +54,7 @@ interface GameShellStoryProps {
|
||||
inventoryUi: InventoryFlowUi;
|
||||
battleRewardUi: BattleRewardUi;
|
||||
questUi: QuestFlowUi;
|
||||
npcChatQuestOfferUi: NpcChatQuestOfferUi;
|
||||
goalUi: GoalFlowUi;
|
||||
}
|
||||
|
||||
@@ -210,6 +212,7 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
|
||||
inventoryUi,
|
||||
battleRewardUi,
|
||||
questUi,
|
||||
npcChatQuestOfferUi,
|
||||
goalUi,
|
||||
} = story;
|
||||
const {
|
||||
@@ -545,6 +548,7 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
|
||||
worldType={visibleGameState.worldType}
|
||||
quests={visibleGameState.quests}
|
||||
questUi={questUi}
|
||||
npcChatQuestOfferUi={npcChatQuestOfferUi}
|
||||
goalStack={goalUi.goalStack}
|
||||
goalPulse={goalUi.pulse}
|
||||
onDismissGoalPulse={goalUi.dismissPulse}
|
||||
|
||||
@@ -24,8 +24,8 @@ function SelectionModal({
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[90] flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm">
|
||||
<div className="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-3xl border border-white/10 bg-[#11161f] shadow-[0_30px_80px_rgba(0,0,0,0.55)]">
|
||||
<div className="platform-overlay fixed inset-0 z-[90] flex items-center justify-center p-4 backdrop-blur-sm">
|
||||
<div className="platform-modal-shell platform-remap-surface flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-3xl shadow-[0_30px_80px_rgba(0,0,0,0.55)]">
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||||
<div className="text-base font-semibold text-white">{title}</div>
|
||||
<button
|
||||
|
||||
@@ -110,6 +110,8 @@ interface AdventurePanelOverlaysProps {
|
||||
selectedRewardEquipSlot: EquipmentSlotId | null;
|
||||
selectedQuestSceneName: string;
|
||||
getQuestStatusLabel: (status: QuestLogEntry['status']) => string;
|
||||
pendingNpcQuestOffer: QuestLogEntry | null;
|
||||
onAcceptPendingNpcQuestOffer: () => string | null;
|
||||
}
|
||||
|
||||
function compactSceneTaskLabel(sceneName: string | null | undefined, fallback: string) {
|
||||
@@ -828,6 +830,8 @@ export function AdventurePanelOverlays({
|
||||
selectedRewardEquipSlot,
|
||||
selectedQuestSceneName,
|
||||
getQuestStatusLabel,
|
||||
pendingNpcQuestOffer,
|
||||
onAcceptPendingNpcQuestOffer,
|
||||
}: AdventurePanelOverlaysProps) {
|
||||
const battleReward = battleRewardUi.reward;
|
||||
const sortedQuests = sortQuestsForGoalPanel(quests, goalStack);
|
||||
@@ -841,6 +845,9 @@ export function AdventurePanelOverlays({
|
||||
setSelectedRewardItemQuestId(quest.id);
|
||||
setSelectedRewardItemId(itemId);
|
||||
};
|
||||
const isPendingSelectedQuest = Boolean(
|
||||
selectedQuest && pendingNpcQuestOffer?.id === selectedQuest.id,
|
||||
);
|
||||
const closeGoalPanel = () => {
|
||||
setIsGoalPanelOpen(false);
|
||||
onDismissGoalPulse();
|
||||
@@ -1244,7 +1251,11 @@ export function AdventurePanelOverlays({
|
||||
quest={selectedQuest}
|
||||
worldType={worldType}
|
||||
sceneName={selectedQuestSceneName}
|
||||
statusLabel={getQuestStatusLabel(selectedQuest.status)}
|
||||
statusLabel={
|
||||
isPendingSelectedQuest
|
||||
? '待领取'
|
||||
: getQuestStatusLabel(selectedQuest.status)
|
||||
}
|
||||
/>
|
||||
<QuestRewardGrid
|
||||
quest={selectedQuest}
|
||||
@@ -1258,7 +1269,26 @@ export function AdventurePanelOverlays({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isQuestReadyToClaim(selectedQuest) && (
|
||||
{isPendingSelectedQuest && (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const acceptedQuestId = onAcceptPendingNpcQuestOffer();
|
||||
if (!acceptedQuestId) return;
|
||||
setSelectedRewardItemQuestId(null);
|
||||
setSelectedRewardItemId(null);
|
||||
setSelectedBattleRewardItemId(null);
|
||||
}}
|
||||
className="pixel-nine-slice pixel-pressable px-4 py-2 text-xs text-white"
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, {paddingX: 14, paddingY: 8})}
|
||||
>
|
||||
领取任务
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isQuestReadyToClaim(selectedQuest) && !isPendingSelectedQuest && (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,57 +1 @@
|
||||
export type PromptDefaultRole = {
|
||||
name: string;
|
||||
title: string;
|
||||
role: string;
|
||||
visualDescription?: string;
|
||||
actionDescription?: string;
|
||||
sceneVisualDescription?: string;
|
||||
description?: string;
|
||||
backstory?: string;
|
||||
personality?: string;
|
||||
motivation?: string;
|
||||
combatStyle?: string;
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
export type CustomWorldRolePromptBundle = {
|
||||
visualPromptText: string;
|
||||
animationPromptText: string;
|
||||
scenePromptText: string;
|
||||
};
|
||||
|
||||
function cleanSeedText(value: string | undefined, maxLength: number) {
|
||||
return (value ?? '').replace(/\s+/gu, ' ').trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
function pickFirstDescription(
|
||||
values: Array<string | undefined>,
|
||||
maxLength: number,
|
||||
) {
|
||||
for (const value of values) {
|
||||
const normalized = cleanSeedText(value, maxLength);
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
export function buildDefaultRolePromptBundle(
|
||||
role: PromptDefaultRole,
|
||||
): CustomWorldRolePromptBundle {
|
||||
return {
|
||||
visualPromptText: pickFirstDescription(
|
||||
[role.visualDescription, role.description],
|
||||
220,
|
||||
),
|
||||
animationPromptText: pickFirstDescription(
|
||||
[role.actionDescription, role.combatStyle],
|
||||
180,
|
||||
),
|
||||
scenePromptText: pickFirstDescription(
|
||||
[role.sceneVisualDescription, role.backstory],
|
||||
220,
|
||||
),
|
||||
};
|
||||
}
|
||||
export * from '../../prompts/customWorldRolePromptDefaults';
|
||||
|
||||
92
src/components/auth/AccountModal.test.tsx
Normal file
92
src/components/auth/AccountModal.test.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
AuthAuditLogEntry,
|
||||
AuthRiskBlockSummary,
|
||||
AuthSessionSummary,
|
||||
AuthUser,
|
||||
} from '../../services/authService';
|
||||
import { AccountModal } from './AccountModal';
|
||||
|
||||
const baseUser: AuthUser = {
|
||||
id: 'user-1',
|
||||
username: 'tester',
|
||||
displayName: '138****8000',
|
||||
phoneNumberMasked: '138****8000',
|
||||
loginMethod: 'phone',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: true,
|
||||
};
|
||||
|
||||
function renderAccountModal(overrides?: {
|
||||
user?: AuthUser;
|
||||
riskBlocks?: AuthRiskBlockSummary[];
|
||||
sessions?: AuthSessionSummary[];
|
||||
auditLogs?: AuthAuditLogEntry[];
|
||||
initialSection?: 'appearance' | 'account' | 'security' | 'devices' | 'logs' | null;
|
||||
}) {
|
||||
return render(
|
||||
<AccountModal
|
||||
user={overrides?.user ?? baseUser}
|
||||
isOpen
|
||||
initialSection={overrides?.initialSection ?? null}
|
||||
platformTheme="light"
|
||||
riskBlocks={overrides?.riskBlocks ?? []}
|
||||
sessions={overrides?.sessions ?? []}
|
||||
auditLogs={overrides?.auditLogs ?? []}
|
||||
loadingRiskBlocks={false}
|
||||
loadingSessions={false}
|
||||
loadingAuditLogs={false}
|
||||
isHydratingSettings={false}
|
||||
isPersistingSettings={false}
|
||||
settingsError={null}
|
||||
onClose={vi.fn()}
|
||||
onPlatformThemeChange={vi.fn()}
|
||||
onLogout={vi.fn().mockResolvedValue(undefined)}
|
||||
onRefreshRiskBlocks={vi.fn().mockResolvedValue(undefined)}
|
||||
onLiftRiskBlock={vi.fn().mockResolvedValue(undefined)}
|
||||
onRefreshSessions={vi.fn().mockResolvedValue(undefined)}
|
||||
onLogoutAll={vi.fn().mockResolvedValue(undefined)}
|
||||
onRefreshAuditLogs={vi.fn().mockResolvedValue(undefined)}
|
||||
onRevokeSession={vi.fn().mockResolvedValue(undefined)}
|
||||
changePhoneCaptchaChallenge={null}
|
||||
onSendChangePhoneCode={vi.fn().mockResolvedValue({
|
||||
cooldownSeconds: 60,
|
||||
expiresInSeconds: 300,
|
||||
})}
|
||||
onChangePhone={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
test('settings header uses a generic title instead of the phone number', () => {
|
||||
renderAccountModal();
|
||||
|
||||
expect(screen.getByRole('dialog', { name: '设置与账号安全' })).toBeTruthy();
|
||||
expect(screen.getByText('设置与账号安全')).toBeTruthy();
|
||||
expect(screen.queryByText('138****8000')).toBeNull();
|
||||
});
|
||||
|
||||
test('account actions open in independent panels instead of inline expansion', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderAccountModal();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /账号信息/ }));
|
||||
|
||||
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
|
||||
expect(accountDialog).toBeTruthy();
|
||||
expect(within(accountDialog).getByRole('button', { name: '返回' })).toBeTruthy();
|
||||
expect(within(accountDialog).getByRole('button', { name: '更换手机号' })).toBeTruthy();
|
||||
expect(screen.queryByLabelText('新手机号')).toBeNull();
|
||||
|
||||
await user.click(within(accountDialog).getByRole('button', { name: '更换手机号' }));
|
||||
|
||||
const changePhoneDialog = screen.getByRole('dialog', { name: '绑定新手机号' });
|
||||
expect(within(changePhoneDialog).getByLabelText('新手机号')).toBeTruthy();
|
||||
expect(within(changePhoneDialog).getByLabelText('验证码')).toBeTruthy();
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -75,7 +75,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
const [showLoginModal, setShowLoginModal] = useState(false);
|
||||
const [showSettingsModal, setShowSettingsModal] = useState(false);
|
||||
const [initialSettingsSection, setInitialSettingsSection] =
|
||||
useState<PlatformSettingsSection>('appearance');
|
||||
useState<PlatformSettingsSection | null>(null);
|
||||
const [showGlobalAccountActions, setShowGlobalAccountActions] = useState(true);
|
||||
const [sessions, setSessions] = useState<AuthSessionSummary[]>([]);
|
||||
const [loadingSessions, setLoadingSessions] = useState(false);
|
||||
@@ -123,9 +123,9 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
);
|
||||
|
||||
const openSettingsModal = useCallback(
|
||||
(section: PlatformSettingsSection = 'appearance') => {
|
||||
(section?: PlatformSettingsSection) => {
|
||||
if (readyUser) {
|
||||
setInitialSettingsSection(section);
|
||||
setInitialSettingsSection(section ?? null);
|
||||
setShowSettingsModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export function CustomWorldAgentClarificationPanel({
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
|
||||
<section className="platform-remap-surface rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
|
||||
@@ -44,7 +44,7 @@ export function CustomWorldAgentComposer({
|
||||
|
||||
return (
|
||||
<div className="shrink-0">
|
||||
<div className="relative">
|
||||
<div className="platform-remap-surface relative">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={text}
|
||||
|
||||
@@ -73,7 +73,7 @@ export function CustomWorldAgentDraftDetailPanel({
|
||||
onOpenRoleAssetStudio,
|
||||
}: CustomWorldAgentDraftDetailPanelProps) {
|
||||
return (
|
||||
<section className="rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
|
||||
<section className="platform-remap-surface rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
|
||||
@@ -38,7 +38,7 @@ export function CustomWorldAgentDraftDrawer({
|
||||
})).filter((group) => group.items.length > 0);
|
||||
|
||||
return (
|
||||
<div className="rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
|
||||
<div className="platform-remap-surface rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
草稿抽屉
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ type CustomWorldAgentHeaderProps = {
|
||||
|
||||
export function CustomWorldAgentHeader({ onBack }: CustomWorldAgentHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center rounded-[1.5rem] border border-white/10 bg-[#111318]/95 px-4 py-3">
|
||||
<div className="platform-remap-surface flex items-center rounded-[1.5rem] border border-white/10 bg-[#111318]/95 px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
|
||||
@@ -54,7 +54,7 @@ export function CustomWorldAgentIntentSummaryPanel({
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
|
||||
<section className="platform-remap-surface rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
|
||||
@@ -24,8 +24,8 @@ export function CustomWorldAgentLauncherModal({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[90] flex items-center justify-center bg-black/72 p-4 backdrop-blur-sm">
|
||||
<div className="flex max-h-[92vh] w-full max-w-2xl flex-col overflow-hidden rounded-[2rem] border border-white/10 bg-[#11161f] shadow-[0_30px_90px_rgba(0,0,0,0.6)]">
|
||||
<div className="platform-overlay fixed inset-0 z-[90] flex items-center justify-center p-4 backdrop-blur-sm">
|
||||
<div className="platform-modal-shell platform-remap-surface flex max-h-[92vh] w-full max-w-2xl flex-col overflow-hidden rounded-[2rem] shadow-[0_30px_90px_rgba(0,0,0,0.6)]">
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-white">
|
||||
|
||||
@@ -28,7 +28,7 @@ export function CustomWorldAgentLockBar({
|
||||
const lockedItems = readLockedItems(lockState);
|
||||
|
||||
return (
|
||||
<div className="rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
|
||||
<div className="platform-remap-surface rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
锁定内容
|
||||
</div>
|
||||
|
||||
@@ -38,7 +38,7 @@ export function CustomWorldAgentOperationBanner({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-[1.4rem] border px-4 py-4 ${
|
||||
className={`platform-remap-surface rounded-[1.4rem] border px-4 py-4 ${
|
||||
isFailed
|
||||
? 'border-rose-400/20 bg-[#111318]/95'
|
||||
: isRunning
|
||||
|
||||
@@ -67,7 +67,7 @@ export function CustomWorldAgentQuickActions({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
|
||||
<div className="platform-remap-surface rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
快捷动作
|
||||
</div>
|
||||
|
||||
@@ -31,7 +31,7 @@ export function CustomWorldAgentSummaryPanel({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rounded-[1.75rem] border border-white/10 bg-black/20 px-4 py-4">
|
||||
<div className="platform-remap-surface rounded-[1.75rem] border border-white/10 bg-black/20 px-4 py-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
|
||||
@@ -37,7 +37,7 @@ export function CustomWorldAgentThread({
|
||||
}, [messages, streamingReplyText, isStreamingReply]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-1 flex-col overflow-y-auto px-1 py-2 sm:px-2">
|
||||
<div className="platform-remap-surface flex h-full min-h-0 flex-1 flex-col overflow-y-auto px-1 py-2 sm:px-2">
|
||||
{messages.length === 0 ? (
|
||||
<div className="m-auto text-sm text-zinc-400">
|
||||
暂无消息
|
||||
|
||||
@@ -42,7 +42,7 @@ export function CustomWorldAgentWorkspace({
|
||||
}: CustomWorldAgentWorkspaceProps) {
|
||||
if (!session) {
|
||||
return (
|
||||
<div className="mx-auto flex h-full w-full max-w-4xl items-center justify-center rounded-[1.75rem] border border-white/10 bg-black/20 px-6 py-8 text-center text-sm text-zinc-400">
|
||||
<div className="platform-remap-surface mx-auto flex h-full w-full max-w-4xl items-center justify-center rounded-[1.75rem] border border-white/10 bg-black/20 px-6 py-8 text-center text-sm text-zinc-400">
|
||||
正在恢复
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -40,7 +40,7 @@ export function CustomWorldDraftCardDetailModal({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[95] flex items-end justify-center bg-black/72 p-3 backdrop-blur-sm xl:hidden">
|
||||
<div className="platform-overlay fixed inset-0 z-[95] flex items-end justify-center p-3 backdrop-blur-sm xl:hidden">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭卡片详情"
|
||||
|
||||
@@ -40,14 +40,14 @@ export function CustomWorldGenerateEntityModal({
|
||||
const submitLabel = mode === 'character' ? '生成角色' : '生成场景';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[96] flex items-end justify-center bg-black/72 p-3 backdrop-blur-sm sm:items-center">
|
||||
<div className="platform-overlay fixed inset-0 z-[96] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭新增弹窗"
|
||||
onClick={onClose}
|
||||
className="absolute inset-0 cursor-default"
|
||||
/>
|
||||
<div className="relative z-10 w-full max-w-xl rounded-[1.8rem] border border-white/10 bg-[linear-gradient(180deg,rgba(10,12,18,0.96),rgba(8,10,14,0.96))] px-4 py-4 shadow-[0_18px_60px_rgba(0,0,0,0.35)] sm:px-5 sm:py-5">
|
||||
<div className="platform-modal-shell platform-remap-surface relative z-10 w-full max-w-xl rounded-[1.8rem] px-4 py-4 shadow-[0_18px_60px_rgba(0,0,0,0.35)] sm:px-5 sm:py-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
|
||||
@@ -48,7 +48,7 @@ export function EightAnchorProgressBar({
|
||||
const canQuickFill = currentTurn >= 2;
|
||||
|
||||
return (
|
||||
<div className="rounded-[1.75rem] border border-white/10 bg-[#111318]/95 p-4">
|
||||
<div className="platform-remap-surface rounded-[1.75rem] border border-white/10 bg-[#111318]/95 p-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
|
||||
@@ -21,7 +21,7 @@ type CustomWorldCreationHubProps = {
|
||||
|
||||
function EmptyState({ title }: { title: string }) {
|
||||
return (
|
||||
<div className="flex min-h-[16rem] flex-col items-center justify-center rounded-[1.8rem] border border-white/8 bg-white/5 px-6 py-8 text-center">
|
||||
<div className="platform-remap-surface flex min-h-[16rem] flex-col items-center justify-center rounded-[1.8rem] border border-white/8 bg-white/5 px-6 py-8 text-center">
|
||||
<div className="text-lg font-semibold text-white">{title}</div>
|
||||
</div>
|
||||
);
|
||||
@@ -56,7 +56,13 @@ export function CustomWorldCreationHub({
|
||||
className="flex h-full min-h-0 flex-col overflow-y-auto overscroll-y-contain pr-1 pb-[max(1rem,env(safe-area-inset-bottom))]"
|
||||
style={{ WebkitOverflowScrolling: 'touch' }}
|
||||
>
|
||||
<div className="sticky top-0 z-20 -mx-3 bg-[linear-gradient(180deg,rgba(10,12,18,0.96),rgba(10,12,18,0.88),rgba(10,12,18,0))] px-3 pb-4 pt-1 sm:static sm:mx-0 sm:bg-none sm:px-0 sm:pb-5 sm:pt-0">
|
||||
<div
|
||||
className="platform-remap-surface sticky top-0 z-20 -mx-3 px-3 pb-4 pt-1 sm:static sm:mx-0 sm:bg-none sm:px-0 sm:pb-5 sm:pt-0"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(180deg, var(--platform-modal-fill), transparent)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<button
|
||||
|
||||
@@ -42,8 +42,8 @@ export function CustomWorldCreationLauncherModal({
|
||||
const unansweredQuestions = questions.filter((question) => !question.answer?.trim());
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[90] flex items-center justify-center bg-black/72 p-4 backdrop-blur-sm">
|
||||
<div className="flex max-h-[92vh] w-full max-w-2xl flex-col overflow-hidden rounded-[2rem] border border-white/10 bg-[#11161f] shadow-[0_30px_90px_rgba(0,0,0,0.6)]">
|
||||
<div className="platform-overlay fixed inset-0 z-[90] flex items-center justify-center p-4 backdrop-blur-sm">
|
||||
<div className="platform-modal-shell platform-remap-surface flex max-h-[92vh] w-full max-w-2xl flex-col overflow-hidden rounded-[2rem] shadow-[0_30px_90px_rgba(0,0,0,0.6)]">
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-white">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
|
||||
import { getNineSliceStyle, UI_CHROME } from '../../uiAssets';
|
||||
|
||||
function formatUpdatedAt(value: string) {
|
||||
@@ -37,15 +38,14 @@ export function CustomWorldWorkCard({
|
||||
paddingY: 15,
|
||||
})}
|
||||
>
|
||||
{item.coverImageSrc ? (
|
||||
<img
|
||||
src={item.coverImageSrc}
|
||||
alt={item.title}
|
||||
className="absolute inset-0 h-full w-full object-cover opacity-20"
|
||||
style={{ imageRendering: 'pixelated' }}
|
||||
/>
|
||||
) : null}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,14,0.12),rgba(8,10,14,0.82))]" />
|
||||
<CustomWorldCoverArtwork
|
||||
imageSrc={item.coverImageSrc}
|
||||
title={item.title}
|
||||
fallbackLabel={item.title.slice(0, 4) || '封面'}
|
||||
renderMode={item.coverRenderMode}
|
||||
characterImageSrcs={item.coverCharacterImageSrcs}
|
||||
className="absolute inset-0"
|
||||
/>
|
||||
<div className="relative z-10 flex h-full min-h-[12rem] flex-col">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
||||
@@ -23,7 +23,7 @@ export function CustomWorldWorkTabs({
|
||||
onChange,
|
||||
}: CustomWorldWorkTabsProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-1">
|
||||
<div className="platform-remap-surface flex items-center gap-2 overflow-x-auto pb-1">
|
||||
{FILTER_OPTIONS.map((option) => {
|
||||
const count =
|
||||
option.id === 'draft'
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
CharacterChatUi,
|
||||
GoalFlowUi,
|
||||
InventoryFlowUi,
|
||||
NpcChatQuestOfferUi,
|
||||
QuestFlowUi,
|
||||
} from '../../hooks/useStoryGeneration';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
@@ -44,7 +45,7 @@ const GameShellStoryPanels = lazy(async () => {
|
||||
function MainContentLoadingFallback({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 items-center justify-center">
|
||||
<div className="rounded-2xl border border-white/10 bg-black/30 px-5 py-4 text-sm text-zinc-300">
|
||||
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-zinc-300">
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,6 +81,7 @@ export function GameShellMainContent({
|
||||
inventoryUi,
|
||||
battleRewardUi,
|
||||
questUi,
|
||||
npcChatQuestOfferUi,
|
||||
goalUi,
|
||||
companionRenderStates,
|
||||
characterChatSummaries,
|
||||
@@ -120,6 +122,7 @@ export function GameShellMainContent({
|
||||
inventoryUi: InventoryFlowUi;
|
||||
battleRewardUi: BattleRewardUi;
|
||||
questUi: QuestFlowUi;
|
||||
npcChatQuestOfferUi: NpcChatQuestOfferUi;
|
||||
goalUi: GoalFlowUi;
|
||||
companionRenderStates: CompanionRenderState[];
|
||||
characterChatSummaries: Record<string, string>;
|
||||
@@ -214,6 +217,7 @@ export function GameShellMainContent({
|
||||
inventoryUi={inventoryUi}
|
||||
battleRewardUi={battleRewardUi}
|
||||
questUi={questUi}
|
||||
npcChatQuestOfferUi={npcChatQuestOfferUi}
|
||||
goalUi={goalUi}
|
||||
companionRenderStates={companionRenderStates}
|
||||
characterChatSummaries={characterChatSummaries}
|
||||
|
||||
@@ -47,6 +47,7 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
|
||||
inventoryUi,
|
||||
battleRewardUi,
|
||||
questUi,
|
||||
npcChatQuestOfferUi,
|
||||
goalUi,
|
||||
} = story;
|
||||
const {
|
||||
@@ -169,6 +170,7 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
|
||||
inventoryUi={inventoryUi}
|
||||
battleRewardUi={battleRewardUi}
|
||||
questUi={questUi}
|
||||
npcChatQuestOfferUi={npcChatQuestOfferUi}
|
||||
goalUi={goalUi}
|
||||
companionRenderStates={companionRenderStates}
|
||||
characterChatSummaries={characterChatSummaries}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
CharacterChatUi,
|
||||
GoalFlowUi,
|
||||
InventoryFlowUi,
|
||||
NpcChatQuestOfferUi,
|
||||
QuestFlowUi,
|
||||
} from '../../hooks/useStoryGeneration';
|
||||
import type { CompanionRenderState, GameState, StoryMoment, StoryOption } from '../../types';
|
||||
@@ -57,6 +58,7 @@ export function GameShellStoryPanels({
|
||||
inventoryUi,
|
||||
battleRewardUi,
|
||||
questUi,
|
||||
npcChatQuestOfferUi,
|
||||
goalUi,
|
||||
companionRenderStates,
|
||||
characterChatSummaries,
|
||||
@@ -85,6 +87,7 @@ export function GameShellStoryPanels({
|
||||
inventoryUi: InventoryFlowUi;
|
||||
battleRewardUi: BattleRewardUi;
|
||||
questUi: QuestFlowUi;
|
||||
npcChatQuestOfferUi: NpcChatQuestOfferUi;
|
||||
goalUi: GoalFlowUi;
|
||||
companionRenderStates: CompanionRenderState[];
|
||||
characterChatSummaries: Record<string, string>;
|
||||
@@ -193,6 +196,7 @@ export function GameShellStoryPanels({
|
||||
worldType={visibleGameState.worldType}
|
||||
quests={visibleGameState.quests}
|
||||
questUi={questUi}
|
||||
npcChatQuestOfferUi={npcChatQuestOfferUi}
|
||||
goalStack={goalUi.goalStack}
|
||||
goalPulse={goalUi.pulse}
|
||||
onDismissGoalPulse={goalUi.dismissPulse}
|
||||
|
||||
19
src/components/game-shell/PlatformBrandLogo.tsx
Normal file
19
src/components/game-shell/PlatformBrandLogo.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
export function PlatformBrandLogo({
|
||||
className = '',
|
||||
decorative = false,
|
||||
}: {
|
||||
className?: string;
|
||||
decorative?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={`platform-brand-logo ${className}`.trim()}
|
||||
role={decorative ? undefined : 'img'}
|
||||
aria-hidden={decorative || undefined}
|
||||
aria-label={decorative ? undefined : '叙世 GENARRATIVE'}
|
||||
>
|
||||
<span className="platform-brand-logo__title">叙世</span>
|
||||
<span className="platform-brand-logo__subtitle">GENARRATIVE</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import type { AuthUser } from '../../services/authService';
|
||||
import type { PlatformBrowseHistoryEntry } from '../../services/platformBrowseHistory';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { PlatformBrandLogo } from './PlatformBrandLogo';
|
||||
import {
|
||||
buildPlatformWorldTags,
|
||||
describePlatformThemeLabel,
|
||||
@@ -47,6 +48,8 @@ export type PlatformHomeTab = 'home' | 'create' | 'saves' | 'profile';
|
||||
const PANEL_SURFACE_CLASS = 'platform-surface platform-surface--soft';
|
||||
const HERO_SURFACE_CLASS =
|
||||
'platform-surface platform-surface--hero platform-interactive-card';
|
||||
const MOBILE_PAGE_STAGE_CLASS = 'platform-page-stage space-y-4 pb-2';
|
||||
const DESKTOP_PAGE_STAGE_CLASS = 'platform-page-stage space-y-5 pb-4';
|
||||
|
||||
function SectionHeader({ title, detail }: { title: string; detail: string }) {
|
||||
return (
|
||||
@@ -67,6 +70,39 @@ function EmptyShelf({ text }: { text: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SaveArchivePreview({
|
||||
entry,
|
||||
label,
|
||||
className,
|
||||
}: {
|
||||
entry: ProfileSaveArchiveSummary;
|
||||
label: string;
|
||||
className: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={`relative shrink-0 overflow-hidden rounded-[1.35rem] border border-white/12 bg-black/18 shadow-[0_16px_36px_rgba(15,23,42,0.18)] ${className}`}
|
||||
>
|
||||
{entry.coverImageSrc ? (
|
||||
<img
|
||||
src={entry.coverImageSrc}
|
||||
alt=""
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.18),transparent_34%),linear-gradient(145deg,rgba(255,94,125,0.92),rgba(255,150,116,0.88))]" />
|
||||
)}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,14,0.06),rgba(8,10,14,0.74))]" />
|
||||
<div className="absolute inset-x-0 bottom-0 px-2.5 py-2">
|
||||
<span className="inline-flex max-w-full items-center rounded-full border border-white/15 bg-black/24 px-2.5 py-1 text-[9px] font-semibold tracking-[0.14em] text-white/88">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorldCard({
|
||||
entry,
|
||||
badge,
|
||||
@@ -155,6 +191,92 @@ function WorldCard({
|
||||
);
|
||||
}
|
||||
|
||||
function CreationLibraryCard({
|
||||
entry,
|
||||
onClick,
|
||||
}: {
|
||||
entry: CustomWorldLibraryEntry<CustomWorldProfile>;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const coverImage = resolvePlatformWorldCoverImage(entry);
|
||||
const leadPortrait = resolvePlatformWorldLeadPortrait(entry);
|
||||
const statusLabel = entry.visibility === 'published' ? '已发布' : '草稿';
|
||||
const metaLabel =
|
||||
entry.visibility === 'published'
|
||||
? formatPlatformWorldTime(entry.publishedAt)
|
||||
: '仅自己可见';
|
||||
const primaryTag =
|
||||
buildPlatformWorldTags(entry)
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean)[0] ?? describePlatformThemeLabel(entry.themeMode);
|
||||
const summaryText =
|
||||
entry.summaryText || entry.subtitle || '继续补完这个世界的设定与游玩入口。';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="platform-surface platform-interactive-card relative flex min-h-[13rem] w-full min-w-0 flex-col overflow-hidden px-3 py-3 text-left sm:min-h-[14rem] sm:px-3.5 sm:py-3.5"
|
||||
>
|
||||
{coverImage ? (
|
||||
<img
|
||||
src={coverImage}
|
||||
alt={entry.worldName}
|
||||
className="absolute inset-0 h-full w-full object-cover opacity-38"
|
||||
/>
|
||||
) : null}
|
||||
{leadPortrait ? (
|
||||
<img
|
||||
src={leadPortrait}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="absolute bottom-1.5 right-1.5 h-16 w-16 object-contain opacity-24 sm:h-20 sm:w-20"
|
||||
/>
|
||||
) : null}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,14,0.1),rgba(8,10,14,0.92))]" />
|
||||
<div className="relative z-10 flex h-full min-w-0 flex-col">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full border px-2 py-1 text-[10px] font-semibold tracking-[0.12em] ${
|
||||
entry.visibility === 'published'
|
||||
? 'border-emerald-300/25 bg-emerald-500/12 text-emerald-50'
|
||||
: 'border-amber-300/25 bg-amber-500/12 text-amber-50'
|
||||
}`}
|
||||
>
|
||||
{statusLabel}
|
||||
</span>
|
||||
<span className="inline-flex min-w-0 max-w-full items-center rounded-full border border-white/12 bg-black/18 px-2 py-1 text-[10px] font-medium text-zinc-300">
|
||||
<span className="truncate">{metaLabel}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto min-w-0">
|
||||
<div className="line-clamp-2 break-words text-base font-black leading-[1.15] text-white sm:text-[1.12rem]">
|
||||
{entry.worldName}
|
||||
</div>
|
||||
{entry.subtitle ? (
|
||||
<div className="mt-1 line-clamp-1 break-words text-[11px] tracking-[0.08em] text-zinc-300/84">
|
||||
{entry.subtitle}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-2 line-clamp-3 break-words text-[11px] leading-5 text-zinc-200/88 sm:text-xs">
|
||||
{summaryText}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
||||
<span className="inline-flex min-w-0 max-w-full items-center rounded-full border border-white/12 bg-black/18 px-2 py-1 text-[10px] font-semibold tracking-[0.1em] text-zinc-100/90">
|
||||
<span className="truncate">{primaryTag}</span>
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-zinc-200">
|
||||
<span>{entry.visibility === 'published' ? '进入世界' : '继续创作'}</span>
|
||||
<ArrowRight className="h-3.5 w-3.5 shrink-0" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SaveArchiveCard({
|
||||
entry,
|
||||
onClick,
|
||||
@@ -164,40 +286,46 @@ function SaveArchiveCard({
|
||||
onClick: () => void;
|
||||
loading?: boolean;
|
||||
}) {
|
||||
const summaryText = entry.summaryText || entry.subtitle || '继续推进上一次保存的故事。';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={loading}
|
||||
className={`platform-surface platform-surface--soft platform-interactive-card relative flex min-h-[12.5rem] w-full overflow-hidden p-4 text-left ${loading ? 'opacity-80' : ''}`}
|
||||
className={`platform-surface platform-surface--soft platform-interactive-card relative flex min-h-[13rem] w-full overflow-hidden p-3.5 text-left sm:min-h-[12.5rem] sm:p-4 ${loading ? 'opacity-80' : ''}`}
|
||||
>
|
||||
{entry.coverImageSrc ? (
|
||||
<img
|
||||
src={entry.coverImageSrc}
|
||||
alt={entry.worldName}
|
||||
className="absolute inset-0 h-full w-full object-cover opacity-24"
|
||||
/>
|
||||
) : null}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,14,0.08),rgba(8,10,14,0.92))]" />
|
||||
<div className="relative z-10 flex h-full w-full flex-col">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.14),transparent_30%),radial-gradient(circle_at_right,rgba(255,205,178,0.14),transparent_28%),linear-gradient(180deg,rgba(8,10,14,0.22),rgba(8,10,14,0.9))]" />
|
||||
<div className="relative z-10 flex h-full w-full flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="platform-pill platform-pill--cool">ARCHIVE</span>
|
||||
<span className="text-[11px] text-zinc-400">
|
||||
<span className="rounded-full border border-white/10 bg-black/18 px-2.5 py-1 text-[11px] font-medium text-zinc-300">
|
||||
{loading ? '恢复中' : formatSnapshotTime(entry.lastPlayedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-auto">
|
||||
<div className="line-clamp-1 text-xl font-black text-white">
|
||||
{entry.worldName}
|
||||
</div>
|
||||
{entry.subtitle ? (
|
||||
<div className="mt-1 line-clamp-1 text-sm text-zinc-300">
|
||||
{entry.subtitle}
|
||||
<div className="flex min-w-0 flex-1 items-stretch gap-3 sm:gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="line-clamp-2 break-words text-[1.15rem] font-black leading-tight text-white sm:text-xl">
|
||||
{entry.worldName}
|
||||
</div>
|
||||
{entry.subtitle ? (
|
||||
<div className="mt-1 line-clamp-1 break-words text-sm text-zinc-300">
|
||||
{entry.subtitle}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-2 line-clamp-3 break-words text-xs leading-5 text-zinc-400 sm:text-sm">
|
||||
{summaryText}
|
||||
</div>
|
||||
<div className="mt-4 inline-flex items-center gap-1.5 text-xs font-semibold text-zinc-200">
|
||||
<span>{loading ? '正在恢复' : '继续游玩'}</span>
|
||||
<ArrowRight className="h-3.5 w-3.5 shrink-0" />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-2 line-clamp-3 text-xs leading-5 text-zinc-400">
|
||||
{entry.summaryText || entry.subtitle || '继续推进上一次保存的故事。'}
|
||||
</div>
|
||||
<SaveArchivePreview
|
||||
entry={entry}
|
||||
label={loading ? '恢复中' : '最近存档'}
|
||||
className="h-[7.4rem] w-[5.6rem] sm:h-[8rem] sm:w-[6.4rem]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@@ -222,24 +350,10 @@ function PlatformTabButton({
|
||||
className={`platform-bottom-nav__button ${active ? 'platform-bottom-nav__button--active' : ''}`}
|
||||
>
|
||||
<span className="flex flex-col items-center justify-center gap-1">
|
||||
<span
|
||||
className={`flex h-6 w-6 items-center justify-center rounded-full ${
|
||||
active
|
||||
? 'bg-[rgba(255,255,255,0.78)] text-[var(--platform-text-strong)] shadow-[0_12px_24px_rgba(255,91,132,0.16)]'
|
||||
: 'bg-[rgba(255,255,255,0.46)] text-[var(--platform-text-soft)]'
|
||||
}`}
|
||||
>
|
||||
<Icon
|
||||
className={`h-[1.05rem] w-[1.05rem] ${
|
||||
active ? 'text-violet-100' : 'text-zinc-400'
|
||||
}`}
|
||||
/>
|
||||
<span className="platform-bottom-nav__icon-shell">
|
||||
<Icon className="platform-bottom-nav__icon h-[1.05rem] w-[1.05rem]" />
|
||||
</span>
|
||||
<span
|
||||
className={`text-[11px] font-semibold tracking-[0.18em] ${
|
||||
active ? 'text-white' : 'text-zinc-400'
|
||||
}`}
|
||||
>
|
||||
<span className="platform-bottom-nav__label text-[11px] font-semibold tracking-[0.18em]">
|
||||
{label}
|
||||
</span>
|
||||
</span>
|
||||
@@ -264,18 +378,10 @@ function DesktopTabButton({
|
||||
onClick={onClick}
|
||||
className={`platform-desktop-rail__button ${active ? 'platform-desktop-rail__button--active' : ''}`}
|
||||
>
|
||||
<span
|
||||
className={`flex h-11 w-11 items-center justify-center rounded-full ${
|
||||
active ? 'bg-white/18 text-white' : 'bg-white/58 text-zinc-500'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-[1.1rem] w-[1.1rem]" />
|
||||
<span className="platform-desktop-rail__icon-shell">
|
||||
<Icon className="platform-desktop-rail__icon h-[1.1rem] w-[1.1rem]" />
|
||||
</span>
|
||||
<span
|
||||
className={`text-[11px] font-semibold tracking-[0.2em] ${
|
||||
active ? 'text-white' : 'text-zinc-400'
|
||||
}`}
|
||||
>
|
||||
<span className="platform-desktop-rail__label text-[11px] font-semibold tracking-[0.2em]">
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
@@ -469,7 +575,7 @@ function ProfileStatCard({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick ? () => onClick(cardKey) : undefined}
|
||||
className="platform-subpanel rounded-[1.35rem] px-4 py-3 text-left transition hover:border-[var(--platform-surface-hover-border)] hover:bg-white/92"
|
||||
className="platform-subpanel rounded-[1.35rem] px-4 py-3 text-left transition hover:border-[var(--platform-surface-hover-border)] hover:bg-[var(--platform-button-secondary-fill)]"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-zinc-400">
|
||||
<Icon className="h-4 w-4" />
|
||||
@@ -504,9 +610,9 @@ function ProfileShortcutButton({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick ?? undefined}
|
||||
className="platform-subpanel flex min-h-[5.25rem] flex-col items-center justify-center gap-2 rounded-[1.2rem] px-3 py-3 text-center transition hover:border-[var(--platform-surface-hover-border)] hover:bg-white/92"
|
||||
className="platform-subpanel flex min-h-[5.25rem] flex-col items-center justify-center gap-2 rounded-[1.2rem] px-3 py-3 text-center transition hover:border-[var(--platform-surface-hover-border)] hover:bg-[var(--platform-button-secondary-fill)]"
|
||||
>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white/8 text-zinc-100">
|
||||
<div className="platform-profile-chip flex h-10 w-10 items-center justify-center rounded-full">
|
||||
<Icon className="h-[1.125rem] w-[1.125rem]" />
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-white">{label}</div>
|
||||
@@ -549,17 +655,14 @@ export function PlatformHomeView({
|
||||
latestEntries: CustomWorldGalleryCard[];
|
||||
myEntries: CustomWorldLibraryEntry<CustomWorldProfile>[];
|
||||
historyEntries: PlatformBrowseHistoryEntry[];
|
||||
historyError: string | null;
|
||||
profileDashboard: ProfileDashboardSummary | null;
|
||||
isLoadingPlatform: boolean;
|
||||
isLoadingDashboard: boolean;
|
||||
isClearingHistory: boolean;
|
||||
isResumingSaveWorldKey: string | null;
|
||||
platformError: string | null;
|
||||
dashboardError: string | null;
|
||||
onContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
|
||||
onResumeSave: (entry: ProfileSaveArchiveSummary) => void;
|
||||
onClearHistory: () => void;
|
||||
onOpenCreateWorld: () => void;
|
||||
onOpenCreateTypePicker: () => void;
|
||||
onOpenGalleryDetail: (entry: CustomWorldGalleryCard) => void;
|
||||
@@ -622,10 +725,17 @@ export function PlatformHomeView({
|
||||
const desktopLibraryPreview = myEntries.slice(0, 2);
|
||||
|
||||
let content = (
|
||||
<div className="space-y-4 pb-2">
|
||||
<div className={MOBILE_PAGE_STAGE_CLASS}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={hasSavedGame ? onContinueGame : onOpenCreateWorld}
|
||||
onClick={() => {
|
||||
if (hasSavedGame) {
|
||||
onContinueGame();
|
||||
return;
|
||||
}
|
||||
|
||||
onOpenCreateWorld();
|
||||
}}
|
||||
className={`${HERO_SURFACE_CLASS} relative block w-full overflow-hidden px-[18px] py-4 text-left`}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.18),transparent_30%),radial-gradient(circle_at_right,rgba(255,205,178,0.18),transparent_28%),linear-gradient(135deg,rgba(255,47,112,0.92),rgba(255,136,104,0.9))]" />
|
||||
@@ -707,7 +817,7 @@ export function PlatformHomeView({
|
||||
|
||||
if (activeTab === 'create') {
|
||||
content = (
|
||||
<div className="space-y-4 pb-2">
|
||||
<div className={MOBILE_PAGE_STAGE_CLASS}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenCreateTypePicker}
|
||||
@@ -736,18 +846,12 @@ export function PlatformHomeView({
|
||||
{isLoadingPlatform ? (
|
||||
<EmptyShelf text="正在读取你的作品..." />
|
||||
) : myEntries.length > 0 ? (
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div className="grid grid-cols-2 gap-2.5 sm:gap-3 xl:grid-cols-3">
|
||||
{myEntries.map(
|
||||
(entry: CustomWorldLibraryEntry<CustomWorldProfile>) => (
|
||||
<WorldCard
|
||||
<CreationLibraryCard
|
||||
key={`${entry.ownerUserId}:${entry.profileId}:mine`}
|
||||
entry={entry}
|
||||
badge={entry.visibility === 'published' ? '已发布' : '草稿'}
|
||||
metaLabel={
|
||||
entry.visibility === 'published'
|
||||
? formatPlatformWorldTime(entry.publishedAt)
|
||||
: '仅自己可见'
|
||||
}
|
||||
onClick={() => onOpenLibraryDetail(entry)}
|
||||
/>
|
||||
),
|
||||
@@ -769,22 +873,15 @@ export function PlatformHomeView({
|
||||
|
||||
if (activeTab === 'saves') {
|
||||
content = (
|
||||
<div className="space-y-4 pb-2">
|
||||
<div className={MOBILE_PAGE_STAGE_CLASS}>
|
||||
{authUi?.user ? (
|
||||
<>
|
||||
<section
|
||||
className={`${HERO_SURFACE_CLASS} relative overflow-hidden px-[18px] py-4 text-left`}
|
||||
>
|
||||
{latestSaveEntry?.coverImageSrc ? (
|
||||
<img
|
||||
src={latestSaveEntry.coverImageSrc}
|
||||
alt={latestSaveEntry.worldName}
|
||||
className="absolute inset-0 h-full w-full object-cover opacity-28"
|
||||
/>
|
||||
) : null}
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.18),transparent_30%),radial-gradient(circle_at_right,rgba(255,205,178,0.18),transparent_28%),linear-gradient(135deg,rgba(255,92,120,0.92),rgba(255,139,98,0.9))]" />
|
||||
<div className="relative z-10 flex min-h-[10rem] flex-col justify-between">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="relative z-10 flex min-h-[10.5rem] flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="platform-pill platform-pill--cool">
|
||||
SAVE ARCHIVE
|
||||
</span>
|
||||
@@ -792,15 +889,24 @@ export function PlatformHomeView({
|
||||
{saveEntries.length > 0 ? `${saveEntries.length} 个存档` : '暂无存档'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-3xl font-black text-white">
|
||||
{latestSaveEntry ? latestSaveEntry.worldName : '存档'}
|
||||
</div>
|
||||
<div className="mt-2 max-w-[30rem] text-sm leading-6 text-zinc-200/88">
|
||||
{latestSaveEntry
|
||||
? `最近更新于 ${formatSnapshotTime(latestSaveEntry.lastPlayedAt)},点开后可直接继续游玩。`
|
||||
: '你在平台里留下的最近可恢复存档会显示在这里。'}
|
||||
<div className="flex min-w-0 items-stretch gap-3 sm:gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="line-clamp-2 break-words text-[1.95rem] font-black leading-[1.02] text-white sm:text-3xl">
|
||||
{latestSaveEntry ? latestSaveEntry.worldName : '存档'}
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-zinc-200/88">
|
||||
{latestSaveEntry
|
||||
? `最近更新于 ${formatSnapshotTime(latestSaveEntry.lastPlayedAt)},点开后可直接继续游玩。`
|
||||
: '你在平台里留下的最近可恢复存档会显示在这里。'}
|
||||
</div>
|
||||
</div>
|
||||
{latestSaveEntry ? (
|
||||
<SaveArchivePreview
|
||||
entry={latestSaveEntry}
|
||||
label="最近更新"
|
||||
className="h-[8.8rem] w-[6.1rem] sm:h-[9.4rem] sm:w-[7rem]"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -851,54 +957,54 @@ export function PlatformHomeView({
|
||||
|
||||
if (activeTab === 'profile') {
|
||||
content = (
|
||||
<div className="space-y-4 pb-2">
|
||||
<div className={MOBILE_PAGE_STAGE_CLASS}>
|
||||
{authUi?.user ? (
|
||||
<>
|
||||
<section className="overflow-hidden rounded-[1.8rem] border border-white/10 bg-[linear-gradient(180deg,rgba(248,244,236,0.96),rgba(232,225,214,0.92))] p-4 text-slate-900 shadow-[0_18px_50px_rgba(0,0,0,0.18)]">
|
||||
<section className="platform-profile-hero rounded-[1.8rem] p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => authUi.openAccountModal()}
|
||||
className="relative h-16 w-16 shrink-0 rounded-[1.4rem] bg-[linear-gradient(135deg,#2a3141,#66718a)] text-white shadow-[0_12px_24px_rgba(15,23,42,0.22)]"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => authUi.openAccountModal()}
|
||||
className="platform-profile-avatar relative h-16 w-16 shrink-0 rounded-[1.4rem]"
|
||||
>
|
||||
<span className="flex h-full w-full items-center justify-center text-2xl font-black">
|
||||
{avatarLabel}
|
||||
</span>
|
||||
<span className="absolute -bottom-1 -right-1 flex h-6 w-6 items-center justify-center rounded-full border border-white/30 bg-white/85 text-slate-700">
|
||||
<span className="platform-profile-camera absolute -bottom-1 -right-1 flex h-6 w-6 items-center justify-center rounded-full">
|
||||
<Camera className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="truncate text-xl font-black text-slate-900">
|
||||
<div className="truncate text-xl font-black text-[var(--platform-text-strong)]">
|
||||
{authUi.user.displayName}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => authUi.openAccountModal()}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full bg-slate-900/6 text-slate-700 transition hover:bg-slate-900/10"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => authUi.openAccountModal()}
|
||||
className="platform-profile-icon-button flex h-7 w-7 items-center justify-center rounded-full"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-slate-500">
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-[var(--platform-text-soft)]">
|
||||
<span>叙世号 {publicUserCode}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyText(publicUserCode)}
|
||||
className="flex items-center gap-1 rounded-full bg-slate-900/6 px-2 py-1 text-slate-700 transition hover:bg-slate-900/10"
|
||||
className="platform-profile-chip flex items-center gap-1 rounded-full px-2 py-1"
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className="rounded-full bg-slate-900/6 px-2.5 py-1 text-slate-700">
|
||||
<span className="platform-profile-chip rounded-full px-2.5 py-1">
|
||||
{describeLoginMethod(authUi.user.loginMethod)}
|
||||
</span>
|
||||
<span className="rounded-full bg-slate-900/6 px-2.5 py-1 text-slate-700">
|
||||
<span className="platform-profile-chip rounded-full px-2.5 py-1">
|
||||
{describeBindingStatus(authUi.user.bindingStatus)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -907,12 +1013,12 @@ export function PlatformHomeView({
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex shrink-0 items-center gap-2 rounded-[1.1rem] bg-[linear-gradient(135deg,#ff4f8b,#ff8a73)] px-3 py-2 text-left text-white shadow-[0_12px_24px_rgba(255,79,139,0.24)]"
|
||||
className="platform-profile-action flex shrink-0 items-center gap-2 rounded-[1.1rem] px-3 py-2 text-left"
|
||||
>
|
||||
<Crown className="h-4 w-4" />
|
||||
<div>
|
||||
<div className="text-xs font-bold">会员充值</div>
|
||||
<div className="text-[10px] text-white/80">普通用户</div>
|
||||
<div className="text-[10px] opacity-80">普通用户</div>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 opacity-80" />
|
||||
</button>
|
||||
@@ -997,18 +1103,16 @@ export function PlatformHomeView({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => authUi.openSettingsModal()}
|
||||
className="platform-subpanel platform-interactive-card flex w-full items-center justify-between gap-3 rounded-[1.25rem] px-4 py-4 text-left transition hover:border-[var(--platform-surface-hover-border)] hover:bg-white/92"
|
||||
className="platform-subpanel platform-interactive-card flex w-full items-center justify-between gap-3 rounded-[1.25rem] px-4 py-4 text-left transition hover:border-[var(--platform-surface-hover-border)] hover:bg-[var(--platform-button-secondary-fill)]"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white/8 text-zinc-100">
|
||||
<div className="platform-profile-chip flex h-10 w-10 items-center justify-center rounded-full">
|
||||
<Settings className="h-[1.125rem] w-[1.125rem]" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-base font-semibold text-white">
|
||||
设置
|
||||
</div>
|
||||
<div className="text-xs text-zinc-400">主题与账号</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-base font-semibold text-white">设置</div>
|
||||
<div className="text-xs text-zinc-400">主题与账号</div>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-zinc-500" />
|
||||
</button>
|
||||
@@ -1034,7 +1138,7 @@ export function PlatformHomeView({
|
||||
|
||||
const desktopContent =
|
||||
activeTab === 'home' ? (
|
||||
<div className="space-y-5 pb-4">
|
||||
<div className={DESKTOP_PAGE_STAGE_CLASS}>
|
||||
{platformError ? (
|
||||
<div className="rounded-[1.5rem] border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
|
||||
{platformError}
|
||||
@@ -1044,7 +1148,14 @@ export function PlatformHomeView({
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,1.55fr)_22rem]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={hasSavedGame ? onContinueGame : onOpenCreateWorld}
|
||||
onClick={() => {
|
||||
if (hasSavedGame) {
|
||||
onContinueGame();
|
||||
return;
|
||||
}
|
||||
|
||||
onOpenCreateWorld();
|
||||
}}
|
||||
className={`${HERO_SURFACE_CLASS} relative block overflow-hidden px-7 py-6 text-left`}
|
||||
>
|
||||
{desktopHeroCover ? (
|
||||
@@ -1172,7 +1283,14 @@ export function PlatformHomeView({
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={hasSavedGame ? onContinueGame : onOpenCreateWorld}
|
||||
onClick={() => {
|
||||
if (hasSavedGame) {
|
||||
onContinueGame();
|
||||
return;
|
||||
}
|
||||
|
||||
onOpenCreateWorld();
|
||||
}}
|
||||
className="platform-surface platform-surface--soft platform-interactive-card relative block w-full overflow-hidden px-5 py-5 text-left"
|
||||
>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(255,164,198,0.18),transparent_28%),radial-gradient(circle_at_bottom_left,rgba(255,202,176,0.18),transparent_32%)]" />
|
||||
@@ -1315,10 +1433,7 @@ export function PlatformHomeView({
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="flex h-full min-h-0 flex-col lg:hidden">
|
||||
<div className="mb-4">
|
||||
<div className="platform-brand-logo" aria-label="叙世 GENARRATIVE">
|
||||
<div className="platform-brand-logo__title">叙世</div>
|
||||
<div className="platform-brand-logo__subtitle">GENARRATIVE</div>
|
||||
</div>
|
||||
<PlatformBrandLogo />
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pr-1 scrollbar-hide">
|
||||
@@ -1362,10 +1477,10 @@ export function PlatformHomeView({
|
||||
<div className="platform-desktop-shell flex h-full min-h-0 flex-col p-5 xl:p-6">
|
||||
<div className="platform-desktop-topbar flex items-center gap-4 px-5 py-4">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-5">
|
||||
<div className="platform-brand-logo shrink-0" aria-hidden="true">
|
||||
<div className="platform-brand-logo__title">叙世</div>
|
||||
<div className="platform-brand-logo__subtitle">GENARRATIVE</div>
|
||||
</div>
|
||||
<PlatformBrandLogo
|
||||
className="shrink-0"
|
||||
decorative
|
||||
/>
|
||||
<div className="platform-desktop-search flex min-w-0 max-w-[34rem] flex-1 items-center gap-3 px-4 py-3 text-zinc-400">
|
||||
<Search className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate text-sm">
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
getCustomWorldAgentSession,
|
||||
streamCustomWorldAgentMessage,
|
||||
} from '../../services/aiService';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
import {
|
||||
clearProfileBrowseHistory,
|
||||
@@ -29,14 +30,30 @@ import {
|
||||
} from '../../services/storageService';
|
||||
import type { GameState } from '../../types';
|
||||
import {
|
||||
type PlatformSettingsSection,
|
||||
AuthUiContext,
|
||||
type PlatformSettingsSection,
|
||||
} from '../auth/AuthUiContext';
|
||||
import {
|
||||
PreGameSelectionFlow,
|
||||
type SelectionStage,
|
||||
} from './PreGameSelectionFlow';
|
||||
|
||||
async function clickFirstButtonByName(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
name: string | RegExp,
|
||||
) {
|
||||
const buttons = screen.getAllByRole('button', { name });
|
||||
await user.click(buttons[0]!);
|
||||
}
|
||||
|
||||
async function clickFirstAsyncButtonByName(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
name: string | RegExp,
|
||||
) {
|
||||
const buttons = await screen.findAllByRole('button', { name });
|
||||
await user.click(buttons[0]!);
|
||||
}
|
||||
|
||||
vi.mock('../../services/aiService', () => ({
|
||||
createCustomWorldAgentSession: vi.fn(),
|
||||
executeCustomWorldAgentAction: vi.fn(),
|
||||
@@ -204,6 +221,26 @@ type TestAuthValue = {
|
||||
settingsError: string | null;
|
||||
};
|
||||
|
||||
function createAuthValue(overrides: Partial<TestAuthValue> = {}): TestAuthValue {
|
||||
return {
|
||||
user: mockAuthUser,
|
||||
openLoginModal: () => {},
|
||||
requireAuth: (action) => action(),
|
||||
openSettingsModal: () => {},
|
||||
openAccountModal: () => {},
|
||||
logout: async () => {},
|
||||
setGlobalAccountActionsVisible: () => {},
|
||||
musicVolume: 0.42,
|
||||
setMusicVolume: () => {},
|
||||
platformTheme: 'light',
|
||||
setPlatformTheme: () => {},
|
||||
isHydratingSettings: false,
|
||||
isPersistingSettings: false,
|
||||
settingsError: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function TestWrapper({
|
||||
withAuth = false,
|
||||
authValue,
|
||||
@@ -211,7 +248,7 @@ function TestWrapper({
|
||||
}: {
|
||||
withAuth?: boolean;
|
||||
authValue?: TestAuthValue;
|
||||
onContinueGame?: (snapshot?: unknown) => void;
|
||||
onContinueGame?: (snapshot?: HydratedSavedGameSnapshot | null) => void;
|
||||
} = {}) {
|
||||
const [selectionStage, setSelectionStage] =
|
||||
useState<SelectionStage>('platform');
|
||||
@@ -235,24 +272,7 @@ function TestWrapper({
|
||||
|
||||
return (
|
||||
<AuthUiContext.Provider
|
||||
value={
|
||||
authValue ?? {
|
||||
user: mockAuthUser,
|
||||
openLoginModal: () => {},
|
||||
requireAuth: (action) => action(),
|
||||
openSettingsModal: () => {},
|
||||
openAccountModal: () => {},
|
||||
logout: async () => {},
|
||||
setGlobalAccountActionsVisible: () => {},
|
||||
musicVolume: 0.42,
|
||||
setMusicVolume: () => {},
|
||||
platformTheme: 'light',
|
||||
setPlatformTheme: () => {},
|
||||
isHydratingSettings: false,
|
||||
isPersistingSettings: false,
|
||||
settingsError: null,
|
||||
}
|
||||
}
|
||||
value={authValue ?? createAuthValue()}
|
||||
>
|
||||
{content}
|
||||
</AuthUiContext.Provider>
|
||||
@@ -292,7 +312,7 @@ beforeEach(() => {
|
||||
bottomTab: 'adventure',
|
||||
currentStory: null,
|
||||
gameState: {} as GameState,
|
||||
},
|
||||
} as HydratedSavedGameSnapshot,
|
||||
});
|
||||
vi.mocked(upsertProfileBrowseHistory).mockResolvedValue([]);
|
||||
vi.mocked(clearProfileBrowseHistory).mockResolvedValue([]);
|
||||
@@ -351,8 +371,8 @@ test('create tab opens game type modal, keeps AIRP and visual novel locked, and
|
||||
|
||||
render(<TestWrapper />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '创作' }));
|
||||
await user.click(screen.getByRole('button', { name: /开启新的创作/u }));
|
||||
await clickFirstButtonByName(user, '创作');
|
||||
await clickFirstButtonByName(user, /开启新的创作/u);
|
||||
|
||||
expect(screen.getByText('选择创作类型')).toBeTruthy();
|
||||
|
||||
@@ -399,14 +419,11 @@ test('clicking a public work while logged out routes through requireAuth', async
|
||||
|
||||
render(
|
||||
<TestWrapper
|
||||
authValue={{
|
||||
authValue={createAuthValue({
|
||||
user: null,
|
||||
openLoginModal: () => {},
|
||||
requireAuth,
|
||||
openAccountModal: () => {},
|
||||
logout: async () => {},
|
||||
setGlobalAccountActionsVisible: () => {},
|
||||
}}
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -425,19 +442,16 @@ test('selecting RPG creation while logged out routes through requireAuth', async
|
||||
|
||||
render(
|
||||
<TestWrapper
|
||||
authValue={{
|
||||
authValue={createAuthValue({
|
||||
user: null,
|
||||
openLoginModal: () => {},
|
||||
requireAuth,
|
||||
openAccountModal: () => {},
|
||||
logout: async () => {},
|
||||
setGlobalAccountActionsVisible: () => {},
|
||||
}}
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '创作' }));
|
||||
await user.click(screen.getByRole('button', { name: /开启新的创作/u }));
|
||||
await clickFirstButtonByName(user, '创作');
|
||||
await clickFirstButtonByName(user, /开启新的创作/u);
|
||||
await user.click(screen.getByRole('button', { name: /角色扮演 RPG/u }));
|
||||
|
||||
expect(requireAuth).toHaveBeenCalledTimes(1);
|
||||
@@ -449,8 +463,8 @@ test('starting draft generation leaves the agent workspace and shows the generat
|
||||
|
||||
render(<TestWrapper />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '创作' }));
|
||||
await user.click(screen.getByRole('button', { name: /开启新的创作/u }));
|
||||
await clickFirstButtonByName(user, '创作');
|
||||
await clickFirstButtonByName(user, /开启新的创作/u);
|
||||
await user.click(screen.getByRole('button', { name: /角色扮演 RPG/u }));
|
||||
|
||||
expect(
|
||||
@@ -582,8 +596,8 @@ test('existing draft sessions enter the legacy result layout directly', async ()
|
||||
|
||||
render(<TestWrapper />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '创作' }));
|
||||
await user.click(screen.getByRole('button', { name: /开启新的创作/u }));
|
||||
await clickFirstButtonByName(user, '创作');
|
||||
await clickFirstButtonByName(user, /开启新的创作/u);
|
||||
await user.click(screen.getByRole('button', { name: /角色扮演 RPG/u }));
|
||||
|
||||
await waitFor(
|
||||
@@ -608,8 +622,6 @@ test('existing draft sessions enter the legacy result layout directly', async ()
|
||||
});
|
||||
|
||||
test('authenticated users with save archives default into the saves tab', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
vi.mocked(listProfileSaveArchives).mockResolvedValue([
|
||||
{
|
||||
worldKey: 'custom:world-1',
|
||||
@@ -626,9 +638,9 @@ test('authenticated users with save archives default into the saves tab', async
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
expect(await screen.findByText('全部存档')).toBeTruthy();
|
||||
expect(await screen.findByText('潮雾列岛')).toBeTruthy();
|
||||
expect(screen.getByText('最近更新时间排序')).toBeTruthy();
|
||||
expect((await screen.findAllByText('全部存档')).length).toBeGreaterThan(0);
|
||||
expect((await screen.findAllByText('潮雾列岛')).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('最近更新时间排序').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('save tab can resume a selected archive directly into the game', async () => {
|
||||
@@ -668,12 +680,12 @@ test('save tab can resume a selected archive directly into the game', async () =
|
||||
gameState: {
|
||||
worldType: 'CUSTOM',
|
||||
} as GameState,
|
||||
},
|
||||
} as HydratedSavedGameSnapshot,
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth onContinueGame={handleContinueGame} />);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: /潮雾列岛/u }));
|
||||
await clickFirstAsyncButtonByName(user, /潮雾列岛/u);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(resumeProfileSaveArchive).toHaveBeenCalledWith('custom:world-1');
|
||||
@@ -719,8 +731,8 @@ test('owned world detail can delete a work and return to the create tab list', a
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '创作' }));
|
||||
await user.click(await screen.findByRole('button', { name: /潮雾列岛/u }));
|
||||
await clickFirstButtonByName(user, '创作');
|
||||
await clickFirstAsyncButtonByName(user, /潮雾列岛/u);
|
||||
await user.click(await screen.findByRole('button', { name: '删除作品' }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -731,6 +743,7 @@ test('owned world detail can delete a work and return to the create tab list', a
|
||||
expect(screen.queryByRole('button', { name: '删除作品' })).toBeNull();
|
||||
});
|
||||
expect(
|
||||
screen.getByText('你还没有保存任何自定义世界,先创建一个草稿开始吧。'),
|
||||
screen.getAllByText('你还没有保存任何自定义世界,先创建一个草稿开始吧。')
|
||||
.length,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -47,7 +47,6 @@ import {
|
||||
buildCustomWorldCreatorIntentFoundationText,
|
||||
} from '../../services/customWorldCreatorIntent';
|
||||
import {
|
||||
clearPlatformBrowseHistory,
|
||||
hasPendingPlatformBrowseHistoryMigration,
|
||||
markPlatformBrowseHistoryMigrated,
|
||||
type PlatformBrowseHistoryEntry,
|
||||
@@ -56,7 +55,6 @@ import {
|
||||
writePlatformBrowseHistory,
|
||||
} from '../../services/platformBrowseHistory';
|
||||
import {
|
||||
clearProfileBrowseHistory,
|
||||
deleteCustomWorldProfile,
|
||||
getCustomWorldGalleryDetail,
|
||||
getProfileDashboard,
|
||||
@@ -171,7 +169,7 @@ function normalizeAgentBackedProfile(profile: CustomWorldProfile) {
|
||||
function LazyPanelFallback({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 items-center justify-center">
|
||||
<div className="rounded-2xl border border-white/10 bg-black/30 px-5 py-4 text-sm text-zinc-300">
|
||||
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-zinc-300">
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
@@ -230,12 +228,11 @@ export function PreGameSelectionFlow({
|
||||
const [profileDashboard, setProfileDashboard] =
|
||||
useState<ProfileDashboardSummary | null>(null);
|
||||
const [dashboardError, setDashboardError] = useState<string | null>(null);
|
||||
const [historyError, setHistoryError] = useState<string | null>(null);
|
||||
const [_historyError, setHistoryError] = useState<string | null>(null);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [detailError, setDetailError] = useState<string | null>(null);
|
||||
const [isLoadingPlatform, setIsLoadingPlatform] = useState(false);
|
||||
const [isLoadingDashboard, setIsLoadingDashboard] = useState(false);
|
||||
const [isClearingHistory, setIsClearingHistory] = useState(false);
|
||||
const [isResumingSaveWorldKey, setIsResumingSaveWorldKey] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
@@ -1049,31 +1046,6 @@ export function PreGameSelectionFlow({
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearBrowseHistory = async () => {
|
||||
if (isClearingHistory || historyEntries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = window.confirm('确认清空全部浏览历史吗?');
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsClearingHistory(true);
|
||||
setHistoryError(null);
|
||||
try {
|
||||
clearPlatformBrowseHistory(authUi?.user);
|
||||
if (authUi?.user) {
|
||||
await clearProfileBrowseHistory();
|
||||
}
|
||||
setHistoryEntries([]);
|
||||
} catch (error) {
|
||||
setHistoryError(resolveErrorMessage(error, '清空浏览历史失败。'));
|
||||
} finally {
|
||||
setIsClearingHistory(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResumeSaveEntry = useCallback(
|
||||
async (entry: ProfileSaveArchiveSummary) => {
|
||||
if (!authUi?.user || isResumingSaveWorldKey) {
|
||||
@@ -1318,11 +1290,9 @@ export function PreGameSelectionFlow({
|
||||
latestEntries={publishedGalleryEntries}
|
||||
myEntries={savedCustomWorldEntries}
|
||||
historyEntries={historyEntries}
|
||||
historyError={historyError}
|
||||
profileDashboard={profileDashboard}
|
||||
isLoadingPlatform={isLoadingPlatform}
|
||||
isLoadingDashboard={isLoadingDashboard}
|
||||
isClearingHistory={isClearingHistory}
|
||||
isResumingSaveWorldKey={isResumingSaveWorldKey}
|
||||
platformError={
|
||||
isLoadingPlatform ? null : (platformError ?? creationTypeError)
|
||||
@@ -1332,9 +1302,6 @@ export function PreGameSelectionFlow({
|
||||
onResumeSave={(entry) => {
|
||||
void handleResumeSaveEntry(entry);
|
||||
}}
|
||||
onClearHistory={() => {
|
||||
void handleClearBrowseHistory();
|
||||
}}
|
||||
onOpenCreateWorld={openCustomWorldCreator}
|
||||
onOpenCreateTypePicker={openCreationTypePicker}
|
||||
onOpenGalleryDetail={(entry) => {
|
||||
@@ -1366,7 +1333,7 @@ export function PreGameSelectionFlow({
|
||||
>
|
||||
{isDetailLoading || !selectedDetailEntry ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="rounded-2xl border border-white/10 bg-black/30 px-5 py-4 text-sm text-zinc-300">
|
||||
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-zinc-300">
|
||||
{detailError || '正在读取作品详情...'}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1452,7 +1419,7 @@ export function PreGameSelectionFlow({
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="rounded-2xl border border-white/10 bg-black/30 px-5 py-4 text-sm text-zinc-300">
|
||||
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-zinc-300">
|
||||
{isLoadingAgentSession
|
||||
? '正在准备 Agent 共创工作区...'
|
||||
: creationTypeError || '正在恢复创作工作区...'}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
CharacterChatUi,
|
||||
GoalFlowUi,
|
||||
InventoryFlowUi,
|
||||
NpcChatQuestOfferUi,
|
||||
QuestFlowUi,
|
||||
StoryGenerationNpcUi,
|
||||
} from '../../hooks/useStoryGeneration';
|
||||
@@ -41,6 +42,7 @@ export interface GameShellStoryProps {
|
||||
inventoryUi: InventoryFlowUi;
|
||||
battleRewardUi: BattleRewardUi;
|
||||
questUi: QuestFlowUi;
|
||||
npcChatQuestOfferUi: NpcChatQuestOfferUi;
|
||||
goalUi: GoalFlowUi;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user