1
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import {
|
||||
import {
|
||||
type ReactNode,
|
||||
useDeferredValue,
|
||||
useEffect,
|
||||
@@ -54,6 +54,8 @@ interface CustomWorldEntityCatalogProps {
|
||||
onProfileChange: (profile: CustomWorldProfile) => void;
|
||||
onDeleteStoryNpcs?: (ids: string[]) => void;
|
||||
onDeleteLandmarks?: (ids: string[]) => void;
|
||||
onGenerateRoleAssets?: (roleId: string) => void;
|
||||
onGenerateSceneAssets?: (sceneId: string, sceneKind: 'camp' | 'landmark') => void;
|
||||
createActionLabel?: string;
|
||||
onCreateAction?: () => void;
|
||||
createActionDisabled?: boolean;
|
||||
@@ -111,10 +113,11 @@ function SmallButton({
|
||||
tone = 'default',
|
||||
disabled = false,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
onClick: React.MouseEventHandler<HTMLButtonElement>;
|
||||
children: ReactNode;
|
||||
tone?: 'default' | 'sky' | 'rose';
|
||||
disabled?: boolean;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
const toneClassName =
|
||||
tone === 'sky'
|
||||
@@ -353,6 +356,7 @@ function CatalogCard({
|
||||
layout = 'stacked',
|
||||
mediaClassName,
|
||||
disabled = false,
|
||||
actions,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
@@ -364,6 +368,7 @@ function CatalogCard({
|
||||
layout?: 'stacked' | 'compact';
|
||||
mediaClassName?: string;
|
||||
disabled?: boolean;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
const selectionBadge = isSelectionMode ? (
|
||||
<div
|
||||
@@ -408,6 +413,7 @@ function CatalogCard({
|
||||
<div className="mt-1.5 text-sm leading-5 text-zinc-300">
|
||||
{description || '暂无描述'}
|
||||
</div>
|
||||
{actions ? <div className="mt-2 flex flex-wrap gap-2">{actions}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@@ -443,6 +449,7 @@ function CatalogCard({
|
||||
<div className="text-sm leading-6 text-zinc-300">
|
||||
{description || '暂无描述'}
|
||||
</div>
|
||||
{actions ? <div className="flex flex-wrap gap-2">{actions}</div> : null}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
@@ -882,6 +889,8 @@ export function CustomWorldEntityCatalog({
|
||||
onProfileChange,
|
||||
onDeleteStoryNpcs,
|
||||
onDeleteLandmarks,
|
||||
onGenerateRoleAssets,
|
||||
onGenerateSceneAssets,
|
||||
createActionLabel,
|
||||
onCreateAction,
|
||||
createActionDisabled = false,
|
||||
@@ -1430,6 +1439,19 @@ export function CustomWorldEntityCatalog({
|
||||
id: role.id,
|
||||
})
|
||||
}
|
||||
actions={
|
||||
!readOnly && onGenerateRoleAssets ? (
|
||||
<SmallButton
|
||||
onClick={(event) => {
|
||||
event?.stopPropagation();
|
||||
onGenerateRoleAssets(role.id);
|
||||
}}
|
||||
tone="sky"
|
||||
>
|
||||
生成资产
|
||||
</SmallButton>
|
||||
) : null
|
||||
}
|
||||
media={
|
||||
role.imageSrc?.trim() ? (
|
||||
<ResolvedAssetImage
|
||||
@@ -1539,6 +1561,19 @@ export function CustomWorldEntityCatalog({
|
||||
id: npc.id,
|
||||
})
|
||||
}
|
||||
actions={
|
||||
!readOnly && !isBulkDeleteMode && onGenerateRoleAssets ? (
|
||||
<SmallButton
|
||||
onClick={(event) => {
|
||||
event?.stopPropagation();
|
||||
onGenerateRoleAssets(npc.id);
|
||||
}}
|
||||
tone="sky"
|
||||
>
|
||||
生成资产
|
||||
</SmallButton>
|
||||
) : null
|
||||
}
|
||||
media={
|
||||
<CustomWorldNpcPortrait
|
||||
npc={npc}
|
||||
@@ -1602,6 +1637,20 @@ export function CustomWorldEntityCatalog({
|
||||
id: scene.id,
|
||||
})
|
||||
}
|
||||
actions={
|
||||
!readOnly && !isBulkDeleteMode && onGenerateSceneAssets ? (
|
||||
<SmallButton
|
||||
onClick={(event) => {
|
||||
event?.stopPropagation();
|
||||
onGenerateSceneAssets(scene.id, scene.kind);
|
||||
}}
|
||||
tone="sky"
|
||||
disabled={scene.kind === 'camp' && isBulkDeleteMode}
|
||||
>
|
||||
生成场景图
|
||||
</SmallButton>
|
||||
) : null
|
||||
}
|
||||
media={
|
||||
<ImageFrame
|
||||
src={scene.imageSrc}
|
||||
@@ -1619,3 +1668,4 @@ export function CustomWorldEntityCatalog({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -443,7 +443,33 @@ test('readOnly result view hides edit and create actions for agent preview mode'
|
||||
expect(screen.queryByRole('button', { name: /批量删除/u })).toBeNull();
|
||||
});
|
||||
|
||||
test('agent result view shows publish blockers and disables publish-enter action', () => {
|
||||
test('agent result view shows error when entity generation returns no new profile', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<RpgCreationResultView
|
||||
profile={baseProfile}
|
||||
previewCharacters={[]}
|
||||
isGenerating={false}
|
||||
progress={0}
|
||||
progressLabel=""
|
||||
error={null}
|
||||
onBack={() => {}}
|
||||
onProfileChange={() => {}}
|
||||
compactAgentResultMode
|
||||
onGenerateEntity={async () => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /场景角色/u }));
|
||||
await user.click(screen.getByRole('button', { name: '新增场景角色' }));
|
||||
|
||||
expect(
|
||||
await screen.findByText(/结果页未收到新增内容/u),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('agent result view keeps publish-enter action clickable and hides sticky publish hints', () => {
|
||||
render(
|
||||
<RpgCreationResultView
|
||||
profile={baseProfile}
|
||||
@@ -474,15 +500,48 @@ test('agent result view shows publish blockers and disables publish-enter action
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/当前结果页数据源:服务端预览/u)).toBeTruthy();
|
||||
expect(screen.getByText(/当前还有 2 个发布阻断项/u)).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(/仍有角色缺少正式主图或动作资产/u),
|
||||
).toBeTruthy();
|
||||
const actionButton = screen.getByRole('button', {
|
||||
name: '发布并进入世界',
|
||||
});
|
||||
expect((actionButton as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((actionButton as HTMLButtonElement).disabled).toBe(false);
|
||||
expect(screen.queryByText(/当前结果页数据源:服务端预览/u)).toBeNull();
|
||||
expect(screen.queryByText(/当前还有 2 个发布阻断项/u)).toBeNull();
|
||||
});
|
||||
|
||||
test('agent result view opens publish blocker dialog only when user clicks publish action', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<RpgCreationResultView
|
||||
profile={baseProfile}
|
||||
previewCharacters={[]}
|
||||
isGenerating={false}
|
||||
progress={0}
|
||||
progressLabel=""
|
||||
error={null}
|
||||
onBack={() => {}}
|
||||
onProfileChange={() => {}}
|
||||
compactAgentResultMode
|
||||
publishReady={false}
|
||||
publishBlockers={[
|
||||
'仍有角色缺少正式主图或动作资产,发布前需要先补齐。',
|
||||
'营地还缺少正式场景图资产,发布前需要先确认营地图。',
|
||||
]}
|
||||
previewSourceLabel="服务端预览"
|
||||
enterWorldActionLabel="发布并进入世界"
|
||||
onEnterWorld={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '发布并进入世界' }));
|
||||
|
||||
expect(
|
||||
screen.getByRole('dialog', { name: '发布前检查' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText(/当前还有 2 个阻断项/u)).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(/仍有角色缺少正式主图或动作资产/u),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('agent result view keeps publish-enter action enabled when publish gate is clear', () => {
|
||||
|
||||
@@ -228,7 +228,7 @@ export function MapModal({
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-3 sm:p-4 backdrop-blur-sm"
|
||||
className="map-modal-overlay fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-3 sm:p-4 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
@@ -236,15 +236,15 @@ export function MapModal({
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.96, y: 8 }}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
className="pixel-nine-slice pixel-modal-shell relative flex max-h-[min(92vh,62rem)] w-full max-w-[min(96vw,64rem)] flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
|
||||
className="platform-remap-surface map-modal-shell pixel-nine-slice pixel-modal-shell relative flex max-h-[min(92vh,62rem)] w-full max-w-[min(96vw,64rem)] flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0"
|
||||
className="map-modal-backdrop pointer-events-none absolute inset-0"
|
||||
style={sceneBackdropStyle}
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(180deg,rgba(2,6,23,0.18),rgba(2,6,23,0.68))]" />
|
||||
<div className="map-modal-shade pointer-events-none absolute inset-0 bg-[linear-gradient(180deg,rgba(2,6,23,0.18),rgba(2,6,23,0.68))]" />
|
||||
<div className="relative border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
|
||||
<div className="min-w-0 pr-10">
|
||||
<div className="inline-flex items-center gap-2 text-[10px] tracking-[0.22em] text-emerald-300/75">
|
||||
@@ -263,7 +263,7 @@ export function MapModal({
|
||||
|
||||
<div className="relative grid min-h-0 flex-1 gap-4 overflow-y-auto p-4 sm:p-5 md:grid-cols-[minmax(15rem,18rem)_minmax(0,1fr)] md:overflow-hidden">
|
||||
<div
|
||||
className="pixel-nine-slice pixel-panel min-w-0 font-mono text-[11px] leading-relaxed text-emerald-100/85 md:max-h-full md:self-start md:overflow-y-auto"
|
||||
className="map-info-panel pixel-nine-slice pixel-panel min-w-0 font-mono text-[11px] leading-relaxed text-emerald-100/85 md:max-h-full md:self-start md:overflow-y-auto"
|
||||
style={getNineSliceStyle(UI_CHROME.infoPanel)}
|
||||
>
|
||||
<div className="text-emerald-200/75">当前位置</div>
|
||||
@@ -365,7 +365,7 @@ export function MapModal({
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/45 p-4 backdrop-blur-[2px]"
|
||||
className="map-modal-overlay fixed inset-0 z-[60] flex items-center justify-center bg-black/45 p-4 backdrop-blur-[2px]"
|
||||
onClick={event => {
|
||||
event.stopPropagation();
|
||||
setPendingScene(null);
|
||||
@@ -376,7 +376,7 @@ export function MapModal({
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.96, y: 10 }}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,36rem)] w-full max-w-md flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.6)]"
|
||||
className="platform-remap-surface map-modal-shell pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,36rem)] w-full max-w-md flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.6)]"
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
|
||||
@@ -48,10 +48,9 @@ function mapBigFishSession(
|
||||
): CreationAgentSessionView {
|
||||
return {
|
||||
sessionId: session.sessionId,
|
||||
title: '大鱼吃小鱼共创',
|
||||
assistantSummary:
|
||||
session.lastAssistantReply ||
|
||||
'先用一句灵感开始,Agent 会收束成可编译的玩法锚点。',
|
||||
// 所有玩法的 Agent 聊天页顶部模块只保留操作与进度,不展示标题和引导副文案。
|
||||
title: null,
|
||||
assistantSummary: null,
|
||||
currentTurn: session.currentTurn,
|
||||
progressPercent: session.progressPercent,
|
||||
anchors: [
|
||||
|
||||
@@ -360,8 +360,8 @@ export function BigFishResultView({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-full min-h-0 w-full max-w-6xl flex-col gap-3 overflow-hidden px-1 sm:px-0">
|
||||
<div className="relative overflow-hidden rounded-[1.8rem] border border-cyan-100/16 bg-[radial-gradient(circle_at_top_left,rgba(45,212,191,0.2),transparent_32%),linear-gradient(135deg,rgba(8,47,73,0.98),rgba(15,23,42,0.98))] px-4 py-4 text-white sm:px-5">
|
||||
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-6xl flex-col gap-3 overflow-hidden px-1 sm:px-0">
|
||||
<div className="platform-result-hero relative overflow-hidden rounded-[1.8rem] border border-cyan-100/16 bg-[radial-gradient(circle_at_top_left,rgba(45,212,191,0.2),transparent_32%),linear-gradient(135deg,rgba(8,47,73,0.98),rgba(15,23,42,0.98))] px-4 py-4 text-white sm:px-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -265,6 +265,43 @@ test('creation agent workspace shows primary and progress actions at completed p
|
||||
expect(screen.getByRole('button', { name: '补全剩余设定' })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('creation agent workspace hides hero copy area when title and summary are absent', () => {
|
||||
if (!Element.prototype.scrollIntoView) {
|
||||
Element.prototype.scrollIntoView = () => {};
|
||||
}
|
||||
|
||||
render(
|
||||
<CreationAgentWorkspace
|
||||
session={{
|
||||
sessionId: 'creation-agent-session-1',
|
||||
title: null,
|
||||
assistantSummary: null,
|
||||
currentTurn: 2,
|
||||
progressPercent: 60,
|
||||
anchors: [],
|
||||
messages: [
|
||||
{
|
||||
id: 'message-1',
|
||||
role: 'assistant',
|
||||
kind: 'chat',
|
||||
text: '继续把设定收束到可生成状态。',
|
||||
},
|
||||
],
|
||||
}}
|
||||
theme={testTheme}
|
||||
loadingText="正在准备"
|
||||
composerPlaceholder="输入消息"
|
||||
primaryActionLabel="生成结果页"
|
||||
onBack={() => {}}
|
||||
onSubmitText={() => {}}
|
||||
onPrimaryAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('统一共创')).toBeNull();
|
||||
expect(screen.getByText('创作进度')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('creation agent workspace stops auto-follow when user scrolls away from bottom', () => {
|
||||
ensureScrollApis();
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ export type CreationAgentOperationView = {
|
||||
|
||||
export type CreationAgentSessionView = {
|
||||
sessionId: string;
|
||||
title: string;
|
||||
title?: string | null;
|
||||
assistantSummary?: string | null;
|
||||
currentTurn: number;
|
||||
progressPercent: number;
|
||||
@@ -311,6 +311,7 @@ export function CreationAgentWorkspace({
|
||||
}
|
||||
|
||||
const progress = normalizeCreationAgentProgress(session.progressPercent);
|
||||
const hasHeroCopy = Boolean(session.title || session.assistantSummary);
|
||||
const canShowPrimaryAction = progress >= 100;
|
||||
const visibleQuickActions = quickActions.filter((action) =>
|
||||
shouldShowQuickAction(action, session, progress),
|
||||
@@ -388,18 +389,22 @@ export function CreationAgentWorkspace({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="text-2xl font-black leading-tight sm:text-3xl">
|
||||
{session.title}
|
||||
{hasHeroCopy ? (
|
||||
<div className="mt-6">
|
||||
{session.title ? (
|
||||
<div className="text-2xl font-black leading-tight sm:text-3xl">
|
||||
{session.title}
|
||||
</div>
|
||||
) : null}
|
||||
{session.assistantSummary ? (
|
||||
<div className="mt-2 max-w-2xl text-sm leading-6 text-white/76">
|
||||
{session.assistantSummary}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{session.assistantSummary ? (
|
||||
<div className="mt-2 max-w-2xl text-sm leading-6 text-white/76">
|
||||
{session.assistantSummary}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4">
|
||||
<div className={hasHeroCopy ? 'mt-4' : 'mt-6'}>
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<span className="text-xs font-semibold tracking-[0.14em] text-white/72">
|
||||
创作进度
|
||||
|
||||
@@ -77,6 +77,10 @@ test('custom world agent workspace renders minimum loop chat layout', () => {
|
||||
expect(html).toContain('输入消息');
|
||||
expect(html).toContain('总结当前设定');
|
||||
expect(html).toContain('补全剩余设定');
|
||||
expect(html).not.toContain('世界共创');
|
||||
expect(html).not.toContain(
|
||||
'先说一个你最想让玩家记住的世界方向,我会帮你收束成可生成草稿的锚点。',
|
||||
);
|
||||
expect(html).not.toContain('Agent');
|
||||
expect(html).not.toContain('刷新');
|
||||
expect(html).not.toContain('当前轮次');
|
||||
|
||||
@@ -83,10 +83,9 @@ function mapCustomWorldSession(
|
||||
): CreationAgentSessionView {
|
||||
return {
|
||||
sessionId: session.sessionId,
|
||||
title: '世界共创',
|
||||
assistantSummary:
|
||||
session.lastAssistantReply ||
|
||||
'先说一个你最想让玩家记住的世界方向,我会帮你收束成可生成草稿的锚点。',
|
||||
// 自定义世界 Agent 聊天页顶部只保留操作与进度,不展示标题和引导副文案。
|
||||
title: null,
|
||||
assistantSummary: null,
|
||||
currentTurn: session.currentTurn,
|
||||
progressPercent: session.progressPercent,
|
||||
anchors: [
|
||||
|
||||
@@ -1357,7 +1357,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden"
|
||||
>
|
||||
<PlatformEntryHomeView
|
||||
activeTab={platformBootstrap.platformTab}
|
||||
@@ -1813,6 +1813,103 @@ export function PlatformEntryFlowShellImpl({
|
||||
});
|
||||
});
|
||||
}}
|
||||
onGenerateEntity={
|
||||
sessionController.isAgentDraftResultView
|
||||
? async (kind) => {
|
||||
const action =
|
||||
kind === 'landmark'
|
||||
? 'generate_landmarks'
|
||||
: 'generate_characters';
|
||||
const latestSession =
|
||||
await autosaveCoordinator.executeAgentActionAndWait({
|
||||
action,
|
||||
count: 1,
|
||||
...(kind === 'playable'
|
||||
? { roleType: 'playable' as const }
|
||||
: kind === 'story'
|
||||
? { roleType: 'story' as const }
|
||||
: {}),
|
||||
});
|
||||
const latestProfile = latestSession
|
||||
? rpgCreationPreviewAdapter.buildPreviewFromSession(
|
||||
latestSession,
|
||||
)
|
||||
: null;
|
||||
if (latestProfile) {
|
||||
sessionController.setGeneratedCustomWorldProfile(
|
||||
latestProfile,
|
||||
);
|
||||
}
|
||||
return { profile: latestProfile };
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onDeleteEntities={
|
||||
sessionController.isAgentDraftResultView
|
||||
? async (kind, ids) => {
|
||||
if (ids.length === 0) return;
|
||||
const latestSession =
|
||||
await autosaveCoordinator.executeAgentActionAndWait(
|
||||
kind === 'story'
|
||||
? { action: 'delete_characters', roleIds: ids }
|
||||
: { action: 'delete_landmarks', sceneIds: ids },
|
||||
);
|
||||
const latestProfile = latestSession
|
||||
? rpgCreationPreviewAdapter.buildPreviewFromSession(
|
||||
latestSession,
|
||||
)
|
||||
: null;
|
||||
if (latestProfile) {
|
||||
sessionController.setGeneratedCustomWorldProfile(
|
||||
latestProfile,
|
||||
);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onGenerateRoleAssets={
|
||||
sessionController.isAgentDraftResultView
|
||||
? async (roleId) => {
|
||||
const latestSession =
|
||||
await autosaveCoordinator.executeAgentActionAndWait({
|
||||
action: 'generate_role_assets',
|
||||
roleIds: [roleId],
|
||||
});
|
||||
const latestProfile = latestSession
|
||||
? rpgCreationPreviewAdapter.buildPreviewFromSession(
|
||||
latestSession,
|
||||
)
|
||||
: null;
|
||||
if (latestProfile) {
|
||||
sessionController.setGeneratedCustomWorldProfile(
|
||||
latestProfile,
|
||||
);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onGenerateSceneAssets={
|
||||
sessionController.isAgentDraftResultView
|
||||
? async (sceneId, sceneKind) => {
|
||||
const latestSession =
|
||||
await autosaveCoordinator.executeAgentActionAndWait({
|
||||
action: 'generate_scene_assets',
|
||||
sceneIds: [sceneId],
|
||||
sceneKind,
|
||||
});
|
||||
const latestProfile = latestSession
|
||||
? rpgCreationPreviewAdapter.buildPreviewFromSession(
|
||||
latestSession,
|
||||
)
|
||||
: null;
|
||||
if (latestProfile) {
|
||||
sessionController.setGeneratedCustomWorldProfile(
|
||||
latestProfile,
|
||||
);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
readOnly={false}
|
||||
compactAgentResultMode={
|
||||
sessionController.isAgentDraftResultView
|
||||
|
||||
@@ -40,10 +40,9 @@ function mapPuzzleSession(
|
||||
): CreationAgentSessionView {
|
||||
return {
|
||||
sessionId: session.sessionId,
|
||||
title: '拼图玩法共创',
|
||||
assistantSummary:
|
||||
session.lastAssistantReply ||
|
||||
'先说一个你最想让玩家一眼记住的画面,我会帮你收束成拼图关卡。',
|
||||
// 所有玩法的 Agent 聊天页顶部模块只保留操作与进度,不展示标题和引导副文案。
|
||||
title: null,
|
||||
assistantSummary: null,
|
||||
currentTurn: session.currentTurn,
|
||||
progressPercent: session.progressPercent,
|
||||
anchors: [
|
||||
|
||||
@@ -229,8 +229,8 @@ export function PuzzleResultView({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-full min-h-0 w-full max-w-6xl flex-col gap-3 overflow-hidden px-1 sm:px-0">
|
||||
<div className="relative overflow-hidden rounded-[1.8rem] border border-amber-100/16 bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.18),transparent_32%),linear-gradient(135deg,rgba(76,29,19,0.98),rgba(15,23,42,0.98))] px-4 py-4 text-white sm:px-5">
|
||||
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-6xl flex-col gap-3 overflow-hidden px-1 sm:px-0">
|
||||
<div className="platform-result-hero relative overflow-hidden rounded-[1.8rem] border border-amber-100/16 bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.18),transparent_32%),linear-gradient(135deg,rgba(76,29,19,0.98),rgba(15,23,42,0.98))] px-4 py-4 text-white sm:px-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { type ReactNode, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
|
||||
function SmallButton({
|
||||
children,
|
||||
@@ -29,6 +32,85 @@ function SmallButton({
|
||||
);
|
||||
}
|
||||
|
||||
function PublishBlockersDialog({
|
||||
blockers,
|
||||
onClose,
|
||||
}: {
|
||||
blockers: string[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
|
||||
|
||||
if (typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[140] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4`}
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="发布前检查"
|
||||
className="platform-modal-shell platform-remap-surface flex max-h-[min(88vh,42rem)] w-full max-w-lg flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.55)] sm:rounded-[1.75rem]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-5 py-4">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
|
||||
发布前还需要补齐这些内容
|
||||
</div>
|
||||
<div className="mt-1 text-sm leading-6 text-[var(--platform-text-soft)]">
|
||||
当前还有 {blockers.length} 个阻断项,补齐后再发布并进入世界。
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="关闭"
|
||||
className="platform-icon-button"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
||||
<div className="space-y-2">
|
||||
{blockers.map((blocker, index) => (
|
||||
<div
|
||||
key={`publish-blocker-${index}-${blocker}`}
|
||||
className="platform-banner platform-banner--warning text-sm leading-6"
|
||||
>
|
||||
<div className="text-xs font-semibold tracking-[0.14em] text-[var(--platform-warm-text)] opacity-80">
|
||||
阻断项 {index + 1}
|
||||
</div>
|
||||
<div className="mt-1 text-[var(--platform-text-strong)]">
|
||||
{blocker}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end border-t border-[var(--platform-subpanel-border)] px-5 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="platform-button platform-button--primary"
|
||||
>
|
||||
我知道了
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
interface RpgCreationResultActionBarProps {
|
||||
editActionLabel: string;
|
||||
enterWorldActionLabel: string;
|
||||
@@ -40,6 +122,7 @@ interface RpgCreationResultActionBarProps {
|
||||
profile: CustomWorldProfile;
|
||||
regenerateActionLabel: string;
|
||||
publishReady: boolean;
|
||||
publishBlockers: string[];
|
||||
}
|
||||
|
||||
export function RpgCreationResultActionBar({
|
||||
@@ -53,7 +136,21 @@ export function RpgCreationResultActionBar({
|
||||
profile,
|
||||
regenerateActionLabel,
|
||||
publishReady,
|
||||
publishBlockers,
|
||||
}: RpgCreationResultActionBarProps) {
|
||||
const [showPublishBlockersDialog, setShowPublishBlockersDialog] =
|
||||
useState(false);
|
||||
|
||||
// 结果页只在用户点击发布动作时展示阻断项,不做吸底常驻提示。
|
||||
const handleEnterWorld = () => {
|
||||
if (!publishReady) {
|
||||
setShowPublishBlockersDialog(true);
|
||||
return;
|
||||
}
|
||||
|
||||
onEnterWorld?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
{profile.generationStatus === 'key_only' ? (
|
||||
@@ -82,14 +179,24 @@ export function RpgCreationResultActionBar({
|
||||
{onEnterWorld ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEnterWorld}
|
||||
disabled={isGenerating || !publishReady}
|
||||
onClick={handleEnterWorld}
|
||||
disabled={isGenerating}
|
||||
className={`platform-button platform-button--primary ${isGenerating ? 'opacity-55' : ''}`}
|
||||
>
|
||||
{enterWorldActionLabel}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{showPublishBlockersDialog ? (
|
||||
<PublishBlockersDialog
|
||||
blockers={
|
||||
publishBlockers.length > 0
|
||||
? publishBlockers
|
||||
: ['当前草稿还没有通过发布门槛,请先补齐必要内容。']
|
||||
}
|
||||
onClose={() => setShowPublishBlockersDialog(false)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import RpgCreationAssetDebugPanel, {
|
||||
import RpgCreationResultActionBar from './RpgCreationResultActionBar';
|
||||
import RpgCreationResultHeader from './RpgCreationResultHeader';
|
||||
import { useRpgCreationResultActions } from './useRpgCreationResultActions';
|
||||
import type { EntityGenerationKind } from './useRpgCreationResultActions';
|
||||
|
||||
export interface RpgCreationResultViewProps {
|
||||
profile: CustomWorldProfile;
|
||||
@@ -25,6 +26,12 @@ export interface RpgCreationResultViewProps {
|
||||
onRegenerate?: () => void;
|
||||
onContinueExpand?: () => void;
|
||||
onEnterWorld?: () => void;
|
||||
onDeleteEntities?: (kind: 'story' | 'landmark', ids: string[]) => Promise<void> | void;
|
||||
onGenerateEntity?:
|
||||
| ((kind: EntityGenerationKind) => Promise<{ profile?: CustomWorldProfile | null } | void> | { profile?: CustomWorldProfile | null } | void)
|
||||
| undefined;
|
||||
onGenerateRoleAssets?: (roleId: string) => Promise<void> | void;
|
||||
onGenerateSceneAssets?: (sceneId: string, sceneKind: 'camp' | 'landmark') => Promise<void> | void;
|
||||
onProfileChange: (profile: CustomWorldProfile) => void;
|
||||
readOnly?: boolean;
|
||||
backLabel?: string;
|
||||
@@ -56,7 +63,11 @@ export function RpgCreationResultView({
|
||||
onEditSetting,
|
||||
onRegenerate: triggerRegenerate,
|
||||
onContinueExpand,
|
||||
onDeleteEntities,
|
||||
onEnterWorld,
|
||||
onGenerateEntity,
|
||||
onGenerateRoleAssets,
|
||||
onGenerateSceneAssets,
|
||||
onProfileChange,
|
||||
readOnly = false,
|
||||
backLabel = '返回',
|
||||
@@ -68,7 +79,6 @@ export function RpgCreationResultView({
|
||||
publishReady = true,
|
||||
publishBlockers = [],
|
||||
qualityFindings = [],
|
||||
previewSourceLabel = null,
|
||||
}: RpgCreationResultViewProps) {
|
||||
const [activeTab, setActiveTab] = useState<ResultTab>('world');
|
||||
const assetDebugEnabled = useMemo(
|
||||
@@ -90,6 +100,11 @@ export function RpgCreationResultView({
|
||||
setEditorTarget,
|
||||
} = useRpgCreationResultActions({
|
||||
activeTab,
|
||||
agentEntityGenerator: onGenerateEntity
|
||||
? async (kind) => {
|
||||
return onGenerateEntity(kind);
|
||||
}
|
||||
: undefined,
|
||||
isGenerating,
|
||||
onProfileChange,
|
||||
profile,
|
||||
@@ -97,8 +112,19 @@ export function RpgCreationResultView({
|
||||
triggerRegenerate,
|
||||
});
|
||||
|
||||
const deleteStoryNpcs = onDeleteEntities
|
||||
? (ids: string[]) => {
|
||||
void onDeleteEntities('story', ids);
|
||||
}
|
||||
: handleDeleteStoryNpcs;
|
||||
const deleteLandmarks = onDeleteEntities
|
||||
? (ids: string[]) => {
|
||||
void onDeleteEntities('landmark', ids);
|
||||
}
|
||||
: handleDeleteLandmarks;
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="platform-remap-surface flex h-full min-h-0 flex-col">
|
||||
<RpgCreationResultHeader
|
||||
autoSaveState={autoSaveState}
|
||||
backLabel={backLabel}
|
||||
@@ -114,13 +140,17 @@ export function RpgCreationResultView({
|
||||
onActiveTabChange={setActiveTab}
|
||||
onEditTarget={setEditorTarget}
|
||||
onProfileChange={onProfileChange}
|
||||
onDeleteStoryNpcs={handleDeleteStoryNpcs}
|
||||
onDeleteLandmarks={handleDeleteLandmarks}
|
||||
onDeleteStoryNpcs={deleteStoryNpcs}
|
||||
onDeleteLandmarks={deleteLandmarks}
|
||||
onGenerateRoleAssets={onGenerateRoleAssets ? (roleId) => { void onGenerateRoleAssets(roleId); } : undefined}
|
||||
onGenerateSceneAssets={onGenerateSceneAssets ? (sceneId, sceneKind) => { void onGenerateSceneAssets(sceneId, sceneKind); } : undefined}
|
||||
createActionLabel={
|
||||
readOnly || compactAgentResultMode ? undefined : createLabel
|
||||
readOnly || (compactAgentResultMode && !onGenerateEntity)
|
||||
? undefined
|
||||
: createLabel
|
||||
}
|
||||
onCreateAction={
|
||||
readOnly || compactAgentResultMode || !createTarget
|
||||
readOnly || (compactAgentResultMode && !onGenerateEntity) || !createTarget
|
||||
? undefined
|
||||
: () => {
|
||||
if (activeTab === 'playable') {
|
||||
@@ -171,25 +201,6 @@ export function RpgCreationResultView({
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
{!error && compactAgentResultMode && previewSourceLabel ? (
|
||||
<div className="platform-banner platform-banner--info mt-3 rounded-2xl text-sm leading-6">
|
||||
当前结果页数据源:{previewSourceLabel}
|
||||
</div>
|
||||
) : null}
|
||||
{!error && compactAgentResultMode && publishBlockers.length > 0 ? (
|
||||
<div className="platform-banner platform-banner--warning mt-3 rounded-2xl text-sm leading-6">
|
||||
{publishReady
|
||||
? '当前世界已满足发布门槛。'
|
||||
: `当前还有 ${publishBlockers.length} 个发布阻断项,请先补齐后再进入世界。`}
|
||||
<div className="mt-2 space-y-1">
|
||||
{publishBlockers.slice(0, 4).map((entry, index) => (
|
||||
<div key={`publish-blocker-${index}-${entry}`}>
|
||||
{index + 1}. {entry}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{!error &&
|
||||
compactAgentResultMode &&
|
||||
publishBlockers.length <= 0 &&
|
||||
@@ -216,6 +227,7 @@ export function RpgCreationResultView({
|
||||
profile={profile}
|
||||
regenerateActionLabel={regenerateActionLabel}
|
||||
publishReady={publishReady}
|
||||
publishBlockers={publishBlockers}
|
||||
/>
|
||||
|
||||
<RpgCreationEntityEditorModal
|
||||
|
||||
@@ -23,6 +23,10 @@ export type PendingGeneratedEntity = {
|
||||
|
||||
export type RecentGeneratedIds = Record<EntityGenerationKind, string[]>;
|
||||
|
||||
export type AgentEntityGenerationResult = {
|
||||
profile?: CustomWorldProfile | null;
|
||||
};
|
||||
|
||||
function getCreateTargetByTab(
|
||||
activeTab: ResultTab,
|
||||
): RpgCreationEditorTarget | null {
|
||||
@@ -99,6 +103,15 @@ function prependLandmark(
|
||||
} satisfies CustomWorldProfile;
|
||||
}
|
||||
|
||||
function getEntityCountByKind(
|
||||
profile: CustomWorldProfile,
|
||||
kind: EntityGenerationKind,
|
||||
) {
|
||||
if (kind === 'playable') return profile.playableNpcs.length;
|
||||
if (kind === 'story') return profile.storyNpcs.length;
|
||||
return profile.landmarks.length;
|
||||
}
|
||||
|
||||
function removeStoryNpcsFromProfile(
|
||||
profile: CustomWorldProfile,
|
||||
ids: string[],
|
||||
@@ -144,6 +157,9 @@ function removeLandmarksFromProfile(
|
||||
|
||||
export function useRpgCreationResultActions(params: {
|
||||
activeTab: ResultTab;
|
||||
agentEntityGenerator?:
|
||||
| ((kind: EntityGenerationKind) => Promise<AgentEntityGenerationResult | void>)
|
||||
| undefined;
|
||||
isGenerating: boolean;
|
||||
onProfileChange: (profile: CustomWorldProfile) => void;
|
||||
profile: CustomWorldProfile;
|
||||
@@ -152,6 +168,7 @@ export function useRpgCreationResultActions(params: {
|
||||
}) {
|
||||
const {
|
||||
activeTab,
|
||||
agentEntityGenerator,
|
||||
isGenerating,
|
||||
onProfileChange,
|
||||
profile,
|
||||
@@ -242,7 +259,16 @@ export function useRpgCreationResultActions(params: {
|
||||
startPendingProgress(kind);
|
||||
|
||||
try {
|
||||
if (kind === 'playable') {
|
||||
if (agentEntityGenerator) {
|
||||
const previousCount = getEntityCountByKind(profile, kind);
|
||||
const generationResult = await agentEntityGenerator(kind);
|
||||
const currentCount = generationResult?.profile
|
||||
? getEntityCountByKind(generationResult.profile, kind)
|
||||
: previousCount;
|
||||
if (currentCount <= previousCount) {
|
||||
throw new Error('生成请求已完成,但结果页未收到新增内容,请返回创作页重新打开草稿后重试。');
|
||||
}
|
||||
} else if (kind === 'playable') {
|
||||
const nextNpc = await rpgCreationAssetClient.generatePlayableNpc({
|
||||
profile,
|
||||
});
|
||||
|
||||
@@ -143,7 +143,7 @@ vi.mock('../big-fish-creation/BigFishAgentWorkspace', () => ({
|
||||
session: { sessionId: string; messages: Array<{ text: string }> } | null;
|
||||
}) => (
|
||||
<div className="big-fish-agent-workspace-mock">
|
||||
大鱼吃小鱼共创:{session?.sessionId ?? 'missing-session'}
|
||||
<div>大鱼吃小鱼工作区:{session?.sessionId ?? 'missing-session'}</div>
|
||||
{session?.messages.map((message) => (
|
||||
<div key={`${session.sessionId}-${message.text}`}>{message.text}</div>
|
||||
))}
|
||||
@@ -1288,6 +1288,27 @@ test('restoring an agent workspace while logged out opens login modal before loa
|
||||
expect(getRpgCreationSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('restoring an agent workspace ignores a stored session owned by another user', async () => {
|
||||
window.sessionStorage.setItem(
|
||||
'genarrative.custom-world-agent-ui.v1',
|
||||
JSON.stringify({
|
||||
activeSessionId: 'custom-world-agent-session-other-user',
|
||||
activeOperationId: null,
|
||||
ownerUserId: 'user-other',
|
||||
}),
|
||||
);
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.sessionStorage.getItem('genarrative.custom-world-agent-ui.v1'))
|
||||
.toBeNull();
|
||||
});
|
||||
|
||||
expect(getRpgCreationSession).not.toHaveBeenCalled();
|
||||
expect(window.location.search).toBe('');
|
||||
});
|
||||
|
||||
test('new creation entry maps raw bearer token errors to user-facing auth copy', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -1410,10 +1431,10 @@ test('puzzle draft card restores the bound agent session and opens the result vi
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '返回' }));
|
||||
|
||||
expect(await screen.findByText('拼图玩法共创')).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText('雨夜里有一只会发光的猫站在遗迹台阶上。'),
|
||||
await screen.findByText('雨夜里有一只会发光的猫站在遗迹台阶上。'),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByText('拼图玩法共创')).toBeNull();
|
||||
});
|
||||
|
||||
test('big fish draft card restores the bound agent session and opens the result view', async () => {
|
||||
@@ -1462,9 +1483,8 @@ test('big fish draft card restores the bound agent session and opens the result
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '返回' }));
|
||||
|
||||
expect(
|
||||
await screen.findByText('大鱼吃小鱼共创:big-fish-session-1'),
|
||||
).toBeTruthy();
|
||||
expect(await screen.findByText('大鱼吃小鱼工作区:big-fish-session-1')).toBeTruthy();
|
||||
expect(screen.queryByText(/大鱼吃小鱼共创/u)).toBeNull();
|
||||
expect(
|
||||
screen.getByText('我想做机械深海里微生物互相吞并进化。'),
|
||||
).toBeTruthy();
|
||||
@@ -1543,7 +1563,7 @@ test('existing draft sessions open result page refinement instead of agent dialo
|
||||
expect(screen.getByRole('button', { name: /AI生成/u })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('agent result view shows publish blockers and disables publish-enter action when preview gate is not ready', async () => {
|
||||
test('agent result view shows publish blocker dialog before publish action when preview gate is not ready', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
vi.mocked(getRpgCreationOperation).mockResolvedValue({
|
||||
@@ -1574,11 +1594,33 @@ test('agent result view shows publish blockers and disables publish-enter action
|
||||
|
||||
await openNewRpgCreation(user);
|
||||
|
||||
expect(await screen.findByText(/当前还有 1 个发布阻断项/u)).toBeTruthy();
|
||||
const actionButton = screen.getByRole('button', {
|
||||
const actionButton = await screen.findByRole('button', {
|
||||
name: /发布并进入世界/u,
|
||||
});
|
||||
expect((actionButton as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((actionButton as HTMLButtonElement).disabled).toBe(false);
|
||||
|
||||
const publishWorldCallCountBeforeClick = vi
|
||||
.mocked(executeRpgCreationAction)
|
||||
.mock.calls.filter(
|
||||
([sessionId, payload]) =>
|
||||
sessionId === 'custom-world-agent-session-1' &&
|
||||
payload?.action === 'publish_world',
|
||||
).length;
|
||||
|
||||
await user.click(actionButton);
|
||||
|
||||
expect(await screen.findByRole('dialog', { name: '发布前检查' })).toBeTruthy();
|
||||
expect(screen.getByText(/当前还有 1 个阻断项/u)).toBeTruthy();
|
||||
expect(screen.getByText(/仍有角色缺少正式主图或动作资产/u)).toBeTruthy();
|
||||
|
||||
const publishWorldCallCountAfterClick = vi
|
||||
.mocked(executeRpgCreationAction)
|
||||
.mock.calls.filter(
|
||||
([sessionId, payload]) =>
|
||||
sessionId === 'custom-world-agent-session-1' &&
|
||||
payload?.action === 'publish_world',
|
||||
).length;
|
||||
expect(publishWorldCallCountAfterClick).toBe(publishWorldCallCountBeforeClick);
|
||||
});
|
||||
|
||||
test('agent draft result publishes before entering world and uses published preview profile', async () => {
|
||||
|
||||
@@ -84,11 +84,11 @@ export interface RpgEntryHomeViewProps {
|
||||
|
||||
const PANEL_SURFACE_CLASS = 'platform-surface platform-surface--soft';
|
||||
const HERO_SURFACE_CLASS =
|
||||
'platform-surface platform-surface--hero platform-interactive-card';
|
||||
'platform-surface platform-surface--hero platform-interactive-card min-w-0';
|
||||
const MOBILE_PAGE_STAGE_CLASS =
|
||||
'platform-page-stage platform-remap-surface space-y-4 pb-2';
|
||||
'platform-page-stage platform-remap-surface min-w-0 space-y-4 overflow-hidden pb-2';
|
||||
const DESKTOP_PAGE_STAGE_CLASS =
|
||||
'platform-page-stage platform-remap-surface space-y-5 pb-4';
|
||||
'platform-page-stage platform-remap-surface min-w-0 space-y-5 pb-4';
|
||||
const DESKTOP_LAYOUT_QUERY = '(min-width: 1024px)';
|
||||
const PLATFORM_HOME_TABS: PlatformHomeTab[] = [
|
||||
'home',
|
||||
@@ -174,7 +174,7 @@ function SectionHeader({ title, detail }: { title: string; detail: string }) {
|
||||
function EmptyShelf({ text }: { text: string }) {
|
||||
return (
|
||||
<div
|
||||
className={`${PANEL_SURFACE_CLASS} rounded-[1.35rem] px-4 py-3 text-sm leading-6 text-[var(--platform-text-base)]`}
|
||||
className={`${PANEL_SURFACE_CLASS} min-w-0 rounded-[1.35rem] px-4 py-3 text-sm leading-6 text-[var(--platform-text-base)]`}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
@@ -241,7 +241,7 @@ function WorldCard({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`platform-surface platform-interactive-card relative flex h-[15rem] w-[15.25rem] shrink-0 flex-col overflow-hidden px-3.5 py-3.5 text-left ${className ?? ''}`}
|
||||
className={`platform-surface platform-interactive-card relative flex h-[15rem] w-[min(15.25rem,78vw)] shrink-0 flex-col overflow-hidden px-3.5 py-3.5 text-left ${className ?? ''}`}
|
||||
>
|
||||
{coverImage ? (
|
||||
<ResolvedAssetBackdrop
|
||||
@@ -852,7 +852,7 @@ export function RpgEntryHomeView({
|
||||
const desktopLibraryPreview = myEntries.slice(0, 2);
|
||||
|
||||
const mobileHomeContent: ReactNode = (
|
||||
<div className={MOBILE_PAGE_STAGE_CLASS}>
|
||||
<div className={`${MOBILE_PAGE_STAGE_CLASS} platform-mobile-home-stage`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -863,29 +863,31 @@ export function RpgEntryHomeView({
|
||||
|
||||
onOpenCreateWorld();
|
||||
}}
|
||||
className={`${HERO_SURFACE_CLASS} relative block w-full overflow-hidden px-[18px] py-4 text-left`}
|
||||
className={`${HERO_SURFACE_CLASS} relative block w-full overflow-hidden px-4 py-4 text-left`}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
|
||||
<div className="relative z-10 flex min-h-[10rem] flex-col justify-between">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<span className="platform-pill platform-pill--warm">
|
||||
<div className="flex min-w-0 flex-wrap items-start justify-between gap-2">
|
||||
<span className="platform-pill platform-pill--warm shrink-0">
|
||||
{hasSavedGame ? 'CONTINUE' : 'CREATE'}
|
||||
</span>
|
||||
<div className="platform-pill platform-pill--neutral px-3 text-[11px] tracking-[0.08em]">
|
||||
<div className="platform-mobile-hero-secondary platform-pill platform-pill--neutral max-w-full px-3 text-[11px] tracking-[0.08em]">
|
||||
{hasSavedGame ? '继续冒险' : '创建世界'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-3xl font-black text-white">
|
||||
<div className="min-w-0">
|
||||
<div className="break-all text-[clamp(1.6rem,7.4vw,1.92rem)] font-black leading-tight text-white">
|
||||
{hasSavedGame ? snapshotWorldName : '写下一个能被游玩的世界'}
|
||||
</div>
|
||||
<div className="mt-2 max-w-[28rem] text-sm leading-6 text-zinc-200/88">
|
||||
<div className="mt-2 max-w-[28rem] break-all text-sm leading-6 text-zinc-200/88">
|
||||
{hasSavedGame
|
||||
? `${snapshotCharacterName} 的进度已保存,点这里回到上一次停下来的故事节点。`
|
||||
: '从设定、角色到场景网络,先生成一版可玩的世界底稿,再继续精修和发布。'}
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-2 text-sm font-semibold text-white/90">
|
||||
<span>{hasSavedGame ? '继续推进故事' : '进入创作工作台'}</span>
|
||||
<div className="mt-4 flex min-w-0 items-center gap-2 text-sm font-semibold text-white/90">
|
||||
<span className="min-w-0 break-all">
|
||||
{hasSavedGame ? '继续推进故事' : '进入创作工作台'}
|
||||
</span>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -903,7 +905,7 @@ export function RpgEntryHomeView({
|
||||
{isLoadingPlatform ? (
|
||||
<EmptyShelf text="正在读取精选作品..." />
|
||||
) : featuredShelf.length > 0 ? (
|
||||
<div className="flex gap-3 overflow-x-auto pb-1 scrollbar-hide">
|
||||
<div className="flex min-w-0 gap-3 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{featuredShelf.map((entry: CustomWorldGalleryCard) => (
|
||||
<WorldCard
|
||||
key={`${entry.ownerUserId}:${entry.profileId}:featured`}
|
||||
@@ -924,7 +926,7 @@ export function RpgEntryHomeView({
|
||||
{isLoadingPlatform ? (
|
||||
<EmptyShelf text="正在读取最新发布..." />
|
||||
) : latestEntries.length > 0 ? (
|
||||
<div className="flex gap-3 overflow-x-auto pb-1 scrollbar-hide">
|
||||
<div className="flex min-w-0 gap-3 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{latestEntries.map((entry: CustomWorldGalleryCard) => (
|
||||
<WorldCard
|
||||
key={`${entry.ownerUserId}:${entry.profileId}:latest`}
|
||||
@@ -947,16 +949,18 @@ export function RpgEntryHomeView({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenCreateTypePicker}
|
||||
className={`${HERO_SURFACE_CLASS} relative block w-full overflow-hidden px-[18px] py-4 text-left`}
|
||||
className={`${HERO_SURFACE_CLASS} relative block w-full overflow-hidden px-4 py-4 text-left`}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
|
||||
<div className="relative z-10 flex min-h-[10rem] flex-col justify-between">
|
||||
<span className="platform-pill platform-pill--cool w-fit">
|
||||
CREATE
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-3xl font-black text-white">开启新的创作</div>
|
||||
<div className="mt-2 max-w-[28rem] text-sm leading-6 text-zinc-200/88">
|
||||
<div className="min-w-0">
|
||||
<div className="break-all text-[clamp(1.6rem,7.4vw,1.92rem)] font-black leading-tight text-white">
|
||||
开启新的创作
|
||||
</div>
|
||||
<div className="mt-2 max-w-[28rem] break-all text-sm leading-6 text-zinc-200/88">
|
||||
先选择游戏类型,再进入对应的创作工作台继续推进。
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-2 text-sm font-semibold text-white/90">
|
||||
@@ -1537,15 +1541,17 @@ export function RpgEntryHomeView({
|
||||
|
||||
if (!isDesktopLayout) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="mb-4">
|
||||
<div className="platform-mobile-entry-shell flex h-full min-h-0 min-w-0 flex-col overflow-hidden">
|
||||
<div className="mb-3 shrink-0 px-0.5">
|
||||
<RpgEntryBrandLogo />
|
||||
</div>
|
||||
|
||||
<div className="platform-tab-panel-stack flex-1">{tabPanels}</div>
|
||||
<div className="platform-tab-panel-stack min-w-0 flex-1">
|
||||
{tabPanels}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="mt-4 border-t pt-3"
|
||||
className="mt-3 min-w-0 shrink-0 border-t pt-2"
|
||||
style={{
|
||||
borderColor: 'var(--platform-line-soft)',
|
||||
paddingBottom: 'calc(env(safe-area-inset-bottom) + 0.2rem)',
|
||||
|
||||
@@ -270,7 +270,7 @@ export function useRpgCreationResultAutosave(
|
||||
const executeAgentActionAndWait = useCallback(
|
||||
async (action: Parameters<typeof executeRpgCreationAction>[1]) => {
|
||||
if (!activeAgentSessionId) {
|
||||
return null;
|
||||
throw new Error('当前世界草稿会话已失效,请返回创作页重新打开草稿。');
|
||||
}
|
||||
|
||||
const { operation } = await executeRpgCreationAction(
|
||||
|
||||
@@ -63,8 +63,14 @@ export function useRpgCreationSessionController(
|
||||
onSessionOpened,
|
||||
} = params;
|
||||
const initialAgentUiStateRef = useRef(readCustomWorldAgentUiState());
|
||||
const isInitialAgentUiStateOwnedByCurrentUser =
|
||||
!initialAgentUiStateRef.current.ownerUserId ||
|
||||
initialAgentUiStateRef.current.ownerUserId === userId;
|
||||
const isHydratingInitialAgentWorkspaceRef = useRef(
|
||||
Boolean(initialAgentUiStateRef.current.activeSessionId),
|
||||
Boolean(
|
||||
initialAgentUiStateRef.current.activeSessionId &&
|
||||
isInitialAgentUiStateOwnedByCurrentUser,
|
||||
),
|
||||
);
|
||||
const hasAppliedInitialAgentWorkspaceRef = useRef(false);
|
||||
const hasRequestedInitialAgentWorkspaceAuthRef = useRef(false);
|
||||
@@ -75,10 +81,18 @@ export function useRpgCreationSessionController(
|
||||
const [isCreatingAgentSession, setIsCreatingAgentSession] = useState(false);
|
||||
const [activeAgentSessionId, setActiveAgentSessionId] = useState<
|
||||
string | null
|
||||
>(() => initialAgentUiStateRef.current.activeSessionId ?? null);
|
||||
>(() =>
|
||||
isInitialAgentUiStateOwnedByCurrentUser
|
||||
? (initialAgentUiStateRef.current.activeSessionId ?? null)
|
||||
: null,
|
||||
);
|
||||
const [activeAgentOperationId, setActiveAgentOperationId] = useState<
|
||||
string | null
|
||||
>(() => initialAgentUiStateRef.current.activeOperationId ?? null);
|
||||
>(() =>
|
||||
isInitialAgentUiStateOwnedByCurrentUser
|
||||
? (initialAgentUiStateRef.current.activeOperationId ?? null)
|
||||
: null,
|
||||
);
|
||||
const [agentSession, setAgentSession] =
|
||||
useState<CustomWorldAgentSessionSnapshot | null>(null);
|
||||
const [agentOperation, setAgentOperation] =
|
||||
@@ -146,9 +160,12 @@ export function useRpgCreationSessionController(
|
||||
writeCustomWorldAgentUiState({
|
||||
activeSessionId: nextSessionId,
|
||||
activeOperationId: nextOperationId,
|
||||
// 工作区 session 是按 userId 持久化的,恢复指针必须绑定当前登录用户,
|
||||
// 避免切换账号或复用旧 URL 时反复请求不属于当前用户的 session 产生 404。
|
||||
ownerUserId: nextSessionId ? userId : null,
|
||||
});
|
||||
},
|
||||
[],
|
||||
[userId],
|
||||
);
|
||||
|
||||
const syncAgentSessionSnapshot = useCallback(async (sessionId: string) => {
|
||||
@@ -192,9 +209,19 @@ export function useRpgCreationSessionController(
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
initialAgentUiStateRef.current.ownerUserId &&
|
||||
initialAgentUiStateRef.current.ownerUserId !== userId
|
||||
) {
|
||||
hasAppliedInitialAgentWorkspaceRef.current = true;
|
||||
isHydratingInitialAgentWorkspaceRef.current = false;
|
||||
persistAgentUiState(null, null);
|
||||
return;
|
||||
}
|
||||
|
||||
hasAppliedInitialAgentWorkspaceRef.current = true;
|
||||
setSelectionStage('agent-workspace');
|
||||
}, [enterCreateTab, openLoginModal, setSelectionStage, userId]);
|
||||
}, [enterCreateTab, openLoginModal, persistAgentUiState, setSelectionStage, userId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeAgentSessionId) {
|
||||
|
||||
@@ -147,7 +147,7 @@ export function RpgRuntimeStageRouter({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${isPlatformShell ? 'platform-main-shell' : 'pixel-app-shell'} flex min-h-0 flex-1 flex-col ${isCharacterSelectionStage ? 'justify-center p-4 sm:p-5' : 'p-3 sm:p-4'}`}
|
||||
className={`${isPlatformShell ? 'platform-main-shell' : 'pixel-app-shell'} flex min-h-0 min-w-0 flex-1 flex-col ${isCharacterSelectionStage ? 'justify-center p-4 sm:p-5' : isPlatformShell ? 'p-2 sm:p-4' : 'p-3 sm:p-4'}`}
|
||||
style={{
|
||||
backgroundColor: isPlatformShell
|
||||
? 'transparent'
|
||||
|
||||
Reference in New Issue
Block a user