1
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent';
|
||||
import { Match3DAgentWorkspace } from './Match3DAgentWorkspace';
|
||||
|
||||
const baseSession: Match3DAgentSessionSnapshot = {
|
||||
sessionId: 'match3d-session-1',
|
||||
currentTurn: 0,
|
||||
progressPercent: 0,
|
||||
stage: 'collecting_config',
|
||||
anchorPack: {
|
||||
theme: {
|
||||
key: 'theme',
|
||||
label: '题材主题',
|
||||
value: '水果摊',
|
||||
status: 'confirmed',
|
||||
},
|
||||
clearCount: {
|
||||
key: 'clearCount',
|
||||
label: '需要消除次数',
|
||||
value: '8',
|
||||
status: 'confirmed',
|
||||
},
|
||||
difficulty: {
|
||||
key: 'difficulty',
|
||||
label: '难度',
|
||||
value: '3',
|
||||
status: 'confirmed',
|
||||
},
|
||||
},
|
||||
config: {
|
||||
themeText: '水果摊',
|
||||
referenceImageSrc: null,
|
||||
clearCount: 8,
|
||||
difficulty: 3,
|
||||
assetStyleId: 'low-poly',
|
||||
assetStyleLabel: '低多边形',
|
||||
assetStylePrompt:
|
||||
'块面清晰、轮廓简洁、颜色分区明确的低多边形 3D 素材风格。',
|
||||
},
|
||||
draft: null,
|
||||
messages: [
|
||||
{
|
||||
id: 'message-1',
|
||||
role: 'assistant',
|
||||
kind: 'chat',
|
||||
text: '旧会话固定追问不再作为主入口。',
|
||||
createdAt: '2026-05-10T10:00:00.000Z',
|
||||
},
|
||||
],
|
||||
lastAssistantReply: '旧会话固定追问不再作为主入口。',
|
||||
publishedProfileId: null,
|
||||
updatedAt: '2026-05-10T10:00:00.000Z',
|
||||
};
|
||||
|
||||
test('match3d workspace submits derived entry form payload instead of agent chat', () => {
|
||||
const onCreateFromForm = vi.fn();
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
render(
|
||||
<Match3DAgentWorkspace
|
||||
session={null}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
onCreateFromForm={onCreateFromForm}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('想做个什么玩法?')).toBeTruthy();
|
||||
expect(screen.getByLabelText('想做一个什么题材的抓大鹅?')).toBeTruthy();
|
||||
expect(screen.getByText('3D素材风格')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '黏土手作' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '自定义' })).toBeTruthy();
|
||||
expect(screen.getByText('消耗20光点')).toBeTruthy();
|
||||
expect(screen.queryByText('参考图')).toBeNull();
|
||||
expect(screen.queryByLabelText('上传抓大鹅参考图')).toBeNull();
|
||||
expect(screen.queryByLabelText('需要消除次数')).toBeNull();
|
||||
expect(screen.queryByLabelText('难度数值')).toBeNull();
|
||||
expect(screen.queryByText('物品')).toBeNull();
|
||||
expect(screen.queryByText('旧会话固定追问不再作为主入口。')).toBeNull();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('想做一个什么题材的抓大鹅?'), {
|
||||
target: { value: '赛博水果摊' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '进阶' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成抓大鹅草稿/u }));
|
||||
|
||||
expect(onCreateFromForm).toHaveBeenCalledWith({
|
||||
seedText: '赛博水果摊题材,消除16次,难度6',
|
||||
themeText: '赛博水果摊',
|
||||
referenceImageSrc: null,
|
||||
clearCount: 16,
|
||||
difficulty: 6,
|
||||
assetStyleId: 'clay-toy',
|
||||
assetStyleLabel: '黏土手作',
|
||||
assetStylePrompt: '圆润、哑光、带轻微手捏痕迹的黏土手作 3D 素材风格。',
|
||||
});
|
||||
expect(onExecuteAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('match3d workspace supports custom 3d asset style prompt', () => {
|
||||
const onCreateFromForm = vi.fn();
|
||||
|
||||
render(
|
||||
<Match3DAgentWorkspace
|
||||
session={null}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
onCreateFromForm={onCreateFromForm}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('想做一个什么题材的抓大鹅?'), {
|
||||
target: { value: '海底甜品店' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '自定义' }));
|
||||
|
||||
expect(screen.getByRole('dialog', { name: '自定义风格' })).toBeTruthy();
|
||||
fireEvent.change(screen.getByLabelText('自定义3D素材风格描述'), {
|
||||
target: { value: '透明果冻材质,边缘有柔和蓝色荧光' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成抓大鹅草稿/u }));
|
||||
|
||||
expect(onCreateFromForm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
seedText: '海底甜品店题材,消除12次,难度4',
|
||||
themeText: '海底甜品店',
|
||||
clearCount: 12,
|
||||
difficulty: 4,
|
||||
assetStyleId: 'custom',
|
||||
assetStyleLabel: '自定义风格',
|
||||
assetStylePrompt: '透明果冻材质,边缘有柔和蓝色荧光',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('match3d workspace falls back to compile action when restored from the legacy route', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
render(
|
||||
<Match3DAgentWorkspace
|
||||
session={baseSession}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
(screen.getByLabelText('想做一个什么题材的抓大鹅?') as HTMLTextAreaElement)
|
||||
.value,
|
||||
).toBe('水果摊');
|
||||
expect(
|
||||
screen.getByRole('button', { name: '轻松' }).getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
expect(
|
||||
screen.getByRole('button', { name: '低多边形' }).getAttribute(
|
||||
'aria-pressed',
|
||||
),
|
||||
).toBe('true');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成抓大鹅草稿/u }));
|
||||
|
||||
expect(onExecuteAction).toHaveBeenCalledWith({
|
||||
action: 'match3d_compile_draft',
|
||||
});
|
||||
});
|
||||
@@ -1,214 +1,531 @@
|
||||
import { useState } from 'react';
|
||||
import { Loader2, Sparkles, WandSparkles, X } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type {
|
||||
CreateMatch3DSessionRequest,
|
||||
ExecuteMatch3DActionRequest,
|
||||
Match3DAgentSessionSnapshot,
|
||||
Match3DAnchorItemResponse,
|
||||
SendMatch3DMessageRequest,
|
||||
} from '../../../packages/shared/src/contracts/match3dAgent';
|
||||
import {
|
||||
buildCreationAgentChatMessage,
|
||||
createCreationAgentChatQuickActions,
|
||||
createCreationAgentClientMessageId,
|
||||
resolveCreationAgentQuickActionMessage,
|
||||
} from '../../services/creation-agent';
|
||||
import {
|
||||
type CreationAgentAnchorView,
|
||||
type CreationAgentSessionView,
|
||||
type CreationAgentTheme,
|
||||
CreationAgentWorkspace,
|
||||
} from '../creation-agent';
|
||||
|
||||
type Match3DAgentWorkspaceProps = {
|
||||
session: Match3DAgentSessionSnapshot | null;
|
||||
streamingReplyText?: string;
|
||||
isStreamingReply?: boolean;
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
onSubmitMessage: (payload: SendMatch3DMessageRequest) => void;
|
||||
onSubmitMessage?: (payload: SendMatch3DMessageRequest) => void;
|
||||
onExecuteAction: (payload: ExecuteMatch3DActionRequest) => void;
|
||||
onCreateFromForm?: (payload: CreateMatch3DSessionRequest) => void;
|
||||
initialFormPayload?: CreateMatch3DSessionRequest | null;
|
||||
showBackButton?: boolean;
|
||||
title?: string | null;
|
||||
};
|
||||
|
||||
type Match3DReferenceImageState = {
|
||||
src: string;
|
||||
label: string;
|
||||
type Match3DFormState = {
|
||||
themeText: string;
|
||||
difficultyOptionId: Match3DDifficultyOptionId;
|
||||
assetStyleId: Match3DAssetStyleOptionId;
|
||||
customAssetStylePrompt: string;
|
||||
};
|
||||
|
||||
const MATCH3D_AGENT_THEME: CreationAgentTheme = {
|
||||
accentTextClass: 'text-lime-100/86',
|
||||
accentBgClass: 'bg-lime-200',
|
||||
accentButtonClass: 'bg-lime-200 shadow-emerald-950/20',
|
||||
userBubbleClass: 'bg-emerald-600 text-white',
|
||||
heroClass:
|
||||
'border border-lime-100/18 bg-[radial-gradient(circle_at_top_left,rgba(190,242,100,0.24),transparent_34%),radial-gradient(circle_at_bottom_right,rgba(251,146,60,0.2),transparent_32%),linear-gradient(135deg,rgba(20,83,45,0.96),rgba(39,39,42,0.96))]',
|
||||
anchorGridClass: 'grid gap-2 sm:grid-cols-3',
|
||||
const EMPTY_FORM_STATE: Match3DFormState = {
|
||||
themeText: '',
|
||||
difficultyOptionId: 'standard',
|
||||
assetStyleId: 'clay-toy',
|
||||
customAssetStylePrompt: '',
|
||||
};
|
||||
|
||||
const MATCH3D_QUICK_ACTIONS = [
|
||||
...createCreationAgentChatQuickActions(),
|
||||
// 中文注释:入口页只暴露难度选项,消除次数和难度数值由选项稳定派生给后端。
|
||||
const MATCH3D_DIFFICULTY_OPTIONS = [
|
||||
{ id: 'easy', label: '轻松', clearCount: 8, difficulty: 2 },
|
||||
{ id: 'standard', label: '标准', clearCount: 12, difficulty: 4 },
|
||||
{ id: 'advanced', label: '进阶', clearCount: 16, difficulty: 6 },
|
||||
{ id: 'hardcore', label: '硬核', clearCount: 20, difficulty: 8 },
|
||||
] as const;
|
||||
|
||||
type Match3DDifficultyOptionId =
|
||||
(typeof MATCH3D_DIFFICULTY_OPTIONS)[number]['id'];
|
||||
|
||||
const MATCH3D_ASSET_STYLE_OPTIONS = [
|
||||
{
|
||||
key: 'match3d-auto-config',
|
||||
label: '自动配置',
|
||||
id: 'clay-toy',
|
||||
label: '黏土手作',
|
||||
imageSrc: '/match3d-style-references/clay-toy.png',
|
||||
prompt: '圆润、哑光、带轻微手捏痕迹的黏土手作 3D 素材风格。',
|
||||
},
|
||||
];
|
||||
{
|
||||
id: 'low-poly',
|
||||
label: '低多边形',
|
||||
imageSrc: '/match3d-style-references/low-poly.png',
|
||||
prompt: '块面清晰、轮廓简洁、颜色分区明确的低多边形 3D 素材风格。',
|
||||
},
|
||||
{
|
||||
id: 'toy-plastic',
|
||||
label: '玩具塑料',
|
||||
imageSrc: '/match3d-style-references/toy-plastic.png',
|
||||
prompt: '亮面、光滑、有柔和高光的玩具塑料 3D 素材风格。',
|
||||
},
|
||||
{
|
||||
id: 'wood-carved',
|
||||
label: '木质雕刻',
|
||||
imageSrc: '/match3d-style-references/wood-carved.png',
|
||||
prompt: '保留木纹和手工雕刻感的温润木质 3D 素材风格。',
|
||||
},
|
||||
{
|
||||
id: 'voxel-block',
|
||||
label: '体素积木',
|
||||
imageSrc: '/match3d-style-references/voxel-block.png',
|
||||
prompt: '由小方块构成、边缘清晰、带游戏感的体素积木 3D 素材风格。',
|
||||
},
|
||||
{
|
||||
id: 'metal-mecha',
|
||||
label: '金属机甲',
|
||||
imageSrc: '/match3d-style-references/metal-mecha.png',
|
||||
prompt: '带金属拉丝、柔和高光和轻科幻感的金属机甲 3D 素材风格。',
|
||||
},
|
||||
{
|
||||
id: 'custom',
|
||||
label: '自定义',
|
||||
imageSrc: null,
|
||||
prompt: '',
|
||||
},
|
||||
] as const;
|
||||
|
||||
function readMatch3DReferenceImageAsDataUrl(file: File) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
reject(new Error('请选择图片文件。'));
|
||||
return;
|
||||
}
|
||||
type Match3DAssetStyleOptionId =
|
||||
(typeof MATCH3D_ASSET_STYLE_OPTIONS)[number]['id'];
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => reject(new Error('参考图读取失败,请重试。'));
|
||||
reader.onload = () => resolve(String(reader.result || ''));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
function normalizeDifficulty(value: number) {
|
||||
return Math.max(1, Math.min(10, Math.round(value)));
|
||||
}
|
||||
|
||||
function mapMatch3DAnchor(
|
||||
anchor: Match3DAnchorItemResponse,
|
||||
): CreationAgentAnchorView {
|
||||
return {
|
||||
key: anchor.key,
|
||||
label: anchor.label,
|
||||
value: anchor.value,
|
||||
status: anchor.status,
|
||||
};
|
||||
}
|
||||
|
||||
function mapMatch3DSession(
|
||||
session: Match3DAgentSessionSnapshot,
|
||||
): CreationAgentSessionView {
|
||||
// 中文注释:抓大鹅 F1 只展示聊天与配置锚点,草稿结果交给后续结果页承接。
|
||||
const chatMessages = session.messages.filter(
|
||||
(message) =>
|
||||
message.kind === 'chat' ||
|
||||
message.kind === 'summary' ||
|
||||
message.kind === 'warning',
|
||||
function resolveDifficultyOptionId(
|
||||
difficulty: number | null | undefined,
|
||||
clearCount: number | null | undefined,
|
||||
): Match3DDifficultyOptionId {
|
||||
const clearCountMatchedOption = MATCH3D_DIFFICULTY_OPTIONS.find(
|
||||
(option) => option.clearCount === clearCount,
|
||||
);
|
||||
if (clearCountMatchedOption) {
|
||||
return clearCountMatchedOption.id;
|
||||
}
|
||||
|
||||
if (typeof difficulty !== 'number' || !Number.isFinite(difficulty)) {
|
||||
return 'standard';
|
||||
}
|
||||
|
||||
const normalizedDifficulty = normalizeDifficulty(Number(difficulty));
|
||||
return MATCH3D_DIFFICULTY_OPTIONS.reduce(
|
||||
(nearestOption, option) =>
|
||||
Math.abs(option.difficulty - normalizedDifficulty) <
|
||||
Math.abs(nearestOption.difficulty - normalizedDifficulty)
|
||||
? option
|
||||
: nearestOption,
|
||||
MATCH3D_DIFFICULTY_OPTIONS[1],
|
||||
).id;
|
||||
}
|
||||
|
||||
function getDifficultyOption(optionId: Match3DDifficultyOptionId) {
|
||||
return (
|
||||
MATCH3D_DIFFICULTY_OPTIONS.find((option) => option.id === optionId) ??
|
||||
MATCH3D_DIFFICULTY_OPTIONS[1]
|
||||
);
|
||||
}
|
||||
|
||||
function getAssetStyleOption(optionId: Match3DAssetStyleOptionId) {
|
||||
return (
|
||||
MATCH3D_ASSET_STYLE_OPTIONS.find((option) => option.id === optionId) ??
|
||||
MATCH3D_ASSET_STYLE_OPTIONS[0]
|
||||
);
|
||||
}
|
||||
|
||||
function resolveAssetStyleOptionId(
|
||||
assetStyleId: string | null | undefined,
|
||||
assetStylePrompt: string | null | undefined,
|
||||
): Match3DAssetStyleOptionId {
|
||||
const matchedOption = MATCH3D_ASSET_STYLE_OPTIONS.find(
|
||||
(option) => option.id === assetStyleId,
|
||||
);
|
||||
if (matchedOption) {
|
||||
return matchedOption.id;
|
||||
}
|
||||
|
||||
return assetStylePrompt?.trim() ? 'custom' : 'clay-toy';
|
||||
}
|
||||
|
||||
function resolveInitialFormState(
|
||||
session: Match3DAgentSessionSnapshot | null,
|
||||
initialFormPayload: CreateMatch3DSessionRequest | null = null,
|
||||
): Match3DFormState {
|
||||
const config = session?.config;
|
||||
const themeText =
|
||||
initialFormPayload?.themeText?.trim() ||
|
||||
config?.themeText?.trim() ||
|
||||
session?.anchorPack.theme.value?.trim() ||
|
||||
initialFormPayload?.seedText?.trim() ||
|
||||
'';
|
||||
const clearCount =
|
||||
initialFormPayload?.clearCount ??
|
||||
config?.clearCount ??
|
||||
null;
|
||||
const difficulty =
|
||||
initialFormPayload?.difficulty ??
|
||||
config?.difficulty ??
|
||||
null;
|
||||
const assetStyleId =
|
||||
initialFormPayload?.assetStyleId ??
|
||||
config?.assetStyleId ??
|
||||
null;
|
||||
const assetStylePrompt =
|
||||
initialFormPayload?.assetStylePrompt ??
|
||||
config?.assetStylePrompt ??
|
||||
'';
|
||||
|
||||
return {
|
||||
sessionId: session.sessionId,
|
||||
title: null,
|
||||
assistantSummary: null,
|
||||
currentTurn: session.currentTurn,
|
||||
progressPercent: session.progressPercent,
|
||||
anchors: [
|
||||
session.anchorPack.theme,
|
||||
session.anchorPack.clearCount,
|
||||
session.anchorPack.difficulty,
|
||||
].map(mapMatch3DAnchor),
|
||||
messages: chatMessages,
|
||||
recommendedReplies: [],
|
||||
...EMPTY_FORM_STATE,
|
||||
themeText,
|
||||
difficultyOptionId: resolveDifficultyOptionId(difficulty, clearCount),
|
||||
assetStyleId: resolveAssetStyleOptionId(assetStyleId, assetStylePrompt),
|
||||
customAssetStylePrompt: assetStylePrompt,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMatch3DChatPayload({
|
||||
text,
|
||||
quickFillRequested = false,
|
||||
referenceImageSrc,
|
||||
}: {
|
||||
text: string;
|
||||
quickFillRequested?: boolean;
|
||||
referenceImageSrc?: string | null;
|
||||
}) {
|
||||
return buildCreationAgentChatMessage<{
|
||||
referenceImageSrc?: string | null;
|
||||
}>({
|
||||
clientMessageId: createCreationAgentClientMessageId('match3d'),
|
||||
text,
|
||||
quickFillRequested,
|
||||
extraPayload: {
|
||||
referenceImageSrc: referenceImageSrc || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 抓大鹅创作入口已从固定 Agent 追问改成表单式。
|
||||
* 组件名保留为 Match3DAgentWorkspace,兼容现有路由、草稿恢复和父层分流。
|
||||
*/
|
||||
export function Match3DAgentWorkspace({
|
||||
session,
|
||||
streamingReplyText = '',
|
||||
isStreamingReply = false,
|
||||
isBusy = false,
|
||||
error = null,
|
||||
onBack,
|
||||
onSubmitMessage,
|
||||
onExecuteAction,
|
||||
onCreateFromForm,
|
||||
initialFormPayload = null,
|
||||
showBackButton = true,
|
||||
title = '想做个什么玩法?',
|
||||
}: Match3DAgentWorkspaceProps) {
|
||||
const [referenceImage, setReferenceImage] =
|
||||
useState<Match3DReferenceImageState | null>(null);
|
||||
const [referenceImageError, setReferenceImageError] = useState<string | null>(
|
||||
null,
|
||||
const [formState, setFormState] = useState<Match3DFormState>(() =>
|
||||
resolveInitialFormState(session, initialFormPayload),
|
||||
);
|
||||
const [isCustomStylePanelOpen, setIsCustomStylePanelOpen] = useState(false);
|
||||
const [draftCustomStylePrompt, setDraftCustomStylePrompt] = useState('');
|
||||
const appliedInitialFormKeyRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const nextInitialFormKey =
|
||||
session?.sessionId ?? JSON.stringify(initialFormPayload ?? null);
|
||||
if (appliedInitialFormKeyRef.current === nextInitialFormKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
appliedInitialFormKeyRef.current = nextInitialFormKey;
|
||||
setFormState(resolveInitialFormState(session, initialFormPayload));
|
||||
setIsCustomStylePanelOpen(false);
|
||||
setDraftCustomStylePrompt('');
|
||||
}, [initialFormPayload, session]);
|
||||
|
||||
const themeText = formState.themeText.trim();
|
||||
const selectedDifficultyOption = getDifficultyOption(
|
||||
formState.difficultyOptionId,
|
||||
);
|
||||
const selectedAssetStyleOption = getAssetStyleOption(formState.assetStyleId);
|
||||
const assetStylePrompt =
|
||||
formState.assetStyleId === 'custom'
|
||||
? formState.customAssetStylePrompt.trim()
|
||||
: selectedAssetStyleOption.prompt;
|
||||
const assetStyleLabel =
|
||||
formState.assetStyleId === 'custom'
|
||||
? '自定义风格'
|
||||
: selectedAssetStyleOption.label;
|
||||
const canSubmit = Boolean(
|
||||
themeText &&
|
||||
!isBusy &&
|
||||
(formState.assetStyleId !== 'custom' ||
|
||||
formState.customAssetStylePrompt.trim()),
|
||||
);
|
||||
const formPayload = useMemo<CreateMatch3DSessionRequest>(
|
||||
() => ({
|
||||
seedText: themeText
|
||||
? `${themeText}题材,消除${selectedDifficultyOption.clearCount}次,难度${selectedDifficultyOption.difficulty}`
|
||||
: themeText,
|
||||
themeText,
|
||||
referenceImageSrc: null,
|
||||
clearCount: selectedDifficultyOption.clearCount,
|
||||
difficulty: selectedDifficultyOption.difficulty,
|
||||
assetStyleId: formState.assetStyleId,
|
||||
assetStyleLabel,
|
||||
assetStylePrompt,
|
||||
}),
|
||||
[
|
||||
assetStyleLabel,
|
||||
assetStylePrompt,
|
||||
formState.assetStyleId,
|
||||
selectedDifficultyOption,
|
||||
themeText,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<CreationAgentWorkspace
|
||||
session={session ? mapMatch3DSession(session) : null}
|
||||
theme={MATCH3D_AGENT_THEME}
|
||||
loadingText="正在准备抓大鹅共创工作区..."
|
||||
composerPlaceholder="题材、消除次数、难度..."
|
||||
primaryActionLabel="生成结果页"
|
||||
streamingReplyText={streamingReplyText}
|
||||
isStreamingReply={isStreamingReply}
|
||||
isBusy={isBusy}
|
||||
error={error}
|
||||
quickActions={MATCH3D_QUICK_ACTIONS}
|
||||
referenceImagePreviewSrc={referenceImage?.src ?? null}
|
||||
referenceImageLabel={referenceImage?.label ?? null}
|
||||
referenceImageError={referenceImageError}
|
||||
onBack={onBack}
|
||||
onSubmitText={(text) => {
|
||||
onSubmitMessage(
|
||||
buildMatch3DChatPayload({
|
||||
text,
|
||||
referenceImageSrc: referenceImage?.src ?? null,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
onPrimaryAction={() => {
|
||||
onExecuteAction({ action: 'match3d_compile_draft' });
|
||||
}}
|
||||
onQuickAction={(action) => {
|
||||
const quickActionMessage =
|
||||
action.key === 'match3d-auto-config'
|
||||
? {
|
||||
text: '自动配置',
|
||||
quickFillRequested: true,
|
||||
}
|
||||
: resolveCreationAgentQuickActionMessage(
|
||||
action.key,
|
||||
'请总结一下当前抓大鹅设定。',
|
||||
);
|
||||
const openCustomStylePanel = () => {
|
||||
setDraftCustomStylePrompt(formState.customAssetStylePrompt);
|
||||
setIsCustomStylePanelOpen(true);
|
||||
};
|
||||
|
||||
onSubmitMessage(
|
||||
buildMatch3DChatPayload({
|
||||
...quickActionMessage,
|
||||
referenceImageSrc: referenceImage?.src ?? null,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
onReferenceImageChange={async (file) => {
|
||||
try {
|
||||
const dataUrl = await readMatch3DReferenceImageAsDataUrl(file);
|
||||
setReferenceImage({
|
||||
src: dataUrl,
|
||||
label: file.name.trim() || '本地参考图',
|
||||
});
|
||||
setReferenceImageError(null);
|
||||
} catch (caughtError) {
|
||||
setReferenceImageError(
|
||||
caughtError instanceof Error
|
||||
? caughtError.message
|
||||
: '参考图读取失败,请重试。',
|
||||
);
|
||||
}
|
||||
}}
|
||||
onClearReferenceImage={() => {
|
||||
setReferenceImage(null);
|
||||
setReferenceImageError(null);
|
||||
}}
|
||||
/>
|
||||
const applyCustomStylePrompt = () => {
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
assetStyleId: 'custom',
|
||||
customAssetStylePrompt: draftCustomStylePrompt.trim(),
|
||||
}));
|
||||
setIsCustomStylePanelOpen(false);
|
||||
};
|
||||
|
||||
const submitForm = () => {
|
||||
if (!canSubmit) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (onCreateFromForm) {
|
||||
onCreateFromForm(formPayload);
|
||||
return;
|
||||
}
|
||||
|
||||
if (session) {
|
||||
onExecuteAction({ action: 'match3d_compile_draft' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col overflow-hidden">
|
||||
{showBackButton ? (
|
||||
<div className="mb-3 flex shrink-0 items-center justify-between gap-3 sm:mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
disabled={isBusy}
|
||||
className={`platform-button platform-button--ghost min-h-0 self-start px-3 py-1.5 text-[11px] ${isBusy ? 'opacity-45' : ''}`}
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden pr-0">
|
||||
{title ? (
|
||||
<div className="mb-3 shrink-0 sm:mb-5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="m-0 text-3xl font-black leading-none tracking-normal text-[var(--platform-text-strong)] sm:text-7xl">
|
||||
{title}
|
||||
</h1>
|
||||
<span className="rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-[11px] font-black text-emerald-700">
|
||||
BETA
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div
|
||||
className={`grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)_auto] gap-2 sm:gap-3 lg:grid-cols-[minmax(0,1.1fr)_minmax(16rem,0.9fr)] lg:grid-rows-1 ${isBusy ? 'opacity-55' : ''}`}
|
||||
>
|
||||
<label className="block min-h-0">
|
||||
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
|
||||
想做一个什么题材的抓大鹅?
|
||||
</span>
|
||||
<textarea
|
||||
value={formState.themeText}
|
||||
disabled={isBusy}
|
||||
rows={5}
|
||||
placeholder=""
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
themeText: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="h-full min-h-[7.75rem] w-full resize-none rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base leading-6 text-[var(--platform-text-strong)] outline-none sm:min-h-[9rem] lg:min-h-[14rem]"
|
||||
aria-label="想做一个什么题材的抓大鹅?"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex min-h-0 flex-col gap-2 overflow-hidden">
|
||||
<div className="min-h-0">
|
||||
<div className="mb-1.5 text-sm font-black text-[var(--platform-text-strong)]">
|
||||
3D素材风格
|
||||
</div>
|
||||
<div
|
||||
className="flex snap-x gap-2 overflow-x-auto overscroll-x-contain pb-1 scrollbar-hide touch-pan-x [-webkit-overflow-scrolling:touch]"
|
||||
aria-label="3D素材风格"
|
||||
>
|
||||
{MATCH3D_ASSET_STYLE_OPTIONS.map((option) => {
|
||||
const selected = formState.assetStyleId === option.id;
|
||||
const isCustom = option.id === 'custom';
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
if (isCustom) {
|
||||
openCustomStylePanel();
|
||||
return;
|
||||
}
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
assetStyleId: option.id,
|
||||
}));
|
||||
}}
|
||||
className={`relative h-[4.45rem] w-[5.2rem] shrink-0 snap-start overflow-hidden rounded-[0.9rem] border p-0 text-left transition sm:h-[5.2rem] sm:w-[6.1rem] ${
|
||||
selected
|
||||
? 'border-[#ff4056] ring-1 ring-inset ring-[#ff4056]'
|
||||
: 'border-[var(--platform-subpanel-border)]'
|
||||
} ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
aria-pressed={selected}
|
||||
aria-label={option.label}
|
||||
>
|
||||
{option.imageSrc ? (
|
||||
<img
|
||||
src={option.imageSrc}
|
||||
alt=""
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<span className="absolute inset-0 bg-[linear-gradient(135deg,rgba(255,255,255,0.98),rgba(255,240,244,0.9))]" />
|
||||
)}
|
||||
<span className="absolute inset-0 bg-[linear-gradient(180deg,rgba(3,7,18,0.02)_0%,rgba(3,7,18,0.1)_42%,rgba(3,7,18,0.82)_100%)]" />
|
||||
{isCustom ? (
|
||||
<span className="absolute inset-0 flex items-center justify-center text-2xl font-black text-[var(--platform-text-strong)]">
|
||||
+
|
||||
</span>
|
||||
) : null}
|
||||
<span className="absolute inset-x-2 bottom-1.5 truncate rounded-full bg-black/26 px-1.5 py-0.5 text-center text-[11px] font-black text-white [text-shadow:0_1px_6px_rgba(0,0,0,0.9)]">
|
||||
{option.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0">
|
||||
<div className="mb-1.5 text-sm font-black text-[var(--platform-text-strong)]">
|
||||
难度
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-1.5 sm:gap-2 lg:grid-cols-2">
|
||||
{MATCH3D_DIFFICULTY_OPTIONS.map((option) => {
|
||||
const selected =
|
||||
formState.difficultyOptionId === option.id;
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
difficultyOptionId: option.id,
|
||||
}))
|
||||
}
|
||||
className={`min-h-10 rounded-[0.85rem] border px-2 text-sm font-black transition sm:min-h-11 ${
|
||||
selected
|
||||
? 'border-[#ff4056] bg-[#ff4056] text-white shadow-[0_8px_18px_rgba(255,64,86,0.18)]'
|
||||
: 'border-[var(--platform-subpanel-border)] bg-white/88 text-[var(--platform-text-strong)]'
|
||||
} ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
aria-pressed={selected}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 shrink-0 space-y-3">
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex shrink-0 justify-center pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:mt-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canSubmit}
|
||||
onClick={submitForm}
|
||||
className={`platform-button platform-button--primary min-h-10 px-4 py-2 text-sm sm:min-h-11 sm:px-5 ${!canSubmit ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
>
|
||||
<span className="inline-flex flex-wrap items-center justify-center gap-1.5 sm:gap-2">
|
||||
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
{session ? (
|
||||
<Sparkles className="h-4 w-4" />
|
||||
) : (
|
||||
<WandSparkles className="h-4 w-4" />
|
||||
)}
|
||||
<span>生成抓大鹅草稿</span>
|
||||
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold">
|
||||
消耗20光点
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isCustomStylePanelOpen ? (
|
||||
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="match3d-custom-style-title"
|
||||
className="platform-modal-shell platform-remap-surface w-full max-w-sm rounded-[1.35rem] p-5 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div
|
||||
id="match3d-custom-style-title"
|
||||
className="text-base font-black text-[var(--platform-text-strong)]"
|
||||
>
|
||||
自定义风格
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭自定义风格"
|
||||
onClick={() => setIsCustomStylePanelOpen(false)}
|
||||
className="platform-profile-icon-button flex h-8 w-8 items-center justify-center rounded-full"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={draftCustomStylePrompt}
|
||||
onChange={(event) => setDraftCustomStylePrompt(event.target.value)}
|
||||
rows={4}
|
||||
className="mt-4 h-[7.5rem] w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base leading-6 text-[var(--platform-text-strong)] outline-none"
|
||||
aria-label="自定义3D素材风格描述"
|
||||
/>
|
||||
<div className="mt-5 grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCustomStylePanelOpen(false)}
|
||||
className="platform-button platform-button--secondary justify-center"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!draftCustomStylePrompt.trim()}
|
||||
onClick={applyCustomStylePrompt}
|
||||
className={`platform-button platform-button--primary justify-center ${!draftCustomStylePrompt.trim() ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
>
|
||||
应用
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { Match3DWorkProfile } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import * as assetReadUrlService from '../../services/assetReadUrlService';
|
||||
import * as hyper3dService from '../../services/hyper3dModelGenerationService';
|
||||
import * as match3dWorksService from '../../services/match3d-works';
|
||||
import { Match3DResultView } from './Match3DResultView';
|
||||
|
||||
@@ -19,13 +21,44 @@ vi.mock('../ResolvedAssetImage', () => ({
|
||||
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
|
||||
useResolvedAssetReadUrl: (src?: string | null) => ({
|
||||
resolvedUrl: src?.startsWith('/generated-')
|
||||
? `https://signed.example.com${src}`
|
||||
: (src ?? ''),
|
||||
isResolving: false,
|
||||
shouldResolve: Boolean(src?.startsWith('/generated-')),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/assetReadUrlService', () => ({
|
||||
readAssetBytes: vi.fn(() =>
|
||||
Promise.resolve(
|
||||
new Response(new Uint8Array([104, 101, 108, 108, 111]), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/match3d-works', () => ({
|
||||
publishMatch3DWork: vi.fn(),
|
||||
updateMatch3DWork: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/hyper3dModelGenerationService', () => ({
|
||||
getHyper3dDownloads: vi.fn(),
|
||||
getHyper3dTaskStatus: vi.fn(),
|
||||
submitHyper3dImageToModel: vi.fn(),
|
||||
submitHyper3dTextToModel: vi.fn(),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function createProfile(
|
||||
@@ -100,4 +133,265 @@ describe('Match3DResultView', () => {
|
||||
fireEvent.click(publishButton);
|
||||
expect(match3dWorksService.publishMatch3DWork).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('结果页提供多 Tab,并能进入 Rodin 3D 素材详情', () => {
|
||||
render(
|
||||
<Match3DResultView
|
||||
profile={createProfile({ themeText: '水果' })}
|
||||
onBack={() => {}}
|
||||
onStartTestRun={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: '作品信息' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '玩法配置' })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: '3D素材' }));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /水果核心物件/u }));
|
||||
|
||||
expect(screen.getByText('素材名称')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '文生模型' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '图生模型' })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('Rodin 文生模型提交使用 Hyper3D 代理', async () => {
|
||||
vi.mocked(hyper3dService.submitHyper3dTextToModel).mockResolvedValue({
|
||||
ok: true,
|
||||
provider: 'hyper3d-rodin',
|
||||
mode: 'text-to-model',
|
||||
taskUuid: 'task-1',
|
||||
subscriptionKey: 'sub-1',
|
||||
jobUuids: ['job-1'],
|
||||
message: 'submitted',
|
||||
tier: 'Gen-2',
|
||||
});
|
||||
|
||||
render(
|
||||
<Match3DResultView
|
||||
profile={createProfile({ themeText: '水果' })}
|
||||
onBack={() => {}}
|
||||
onStartTestRun={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '3D素材' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /水果核心物件/u }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hyper3dService.submitHyper3dTextToModel).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
geometryFileFormat: 'glb',
|
||||
material: 'PBR',
|
||||
meshMode: 'Quad',
|
||||
prompt: expect.stringContaining('水果核心物件'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('排队中').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('Rodin 图生模型没有参考图时阻止提交', async () => {
|
||||
render(
|
||||
<Match3DResultView
|
||||
profile={createProfile({ themeText: '水果' })}
|
||||
onBack={() => {}}
|
||||
onStartTestRun={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '3D素材' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /水果核心物件/u }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '图生模型' }));
|
||||
|
||||
const generateButton = screen.getByRole('button', { name: '生成' });
|
||||
expect(generateButton).toHaveProperty('disabled', true);
|
||||
expect(hyper3dService.submitHyper3dImageToModel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('结果页优先预览生成出来的物品图片和模型文件', () => {
|
||||
render(
|
||||
<Match3DResultView
|
||||
profile={createProfile({
|
||||
clearCount: 3,
|
||||
generatedItemAssets: [
|
||||
{
|
||||
itemId: 'match3d-item-1',
|
||||
itemName: '草莓',
|
||||
imageSrc: '/generated-match3d-assets/session/profile/items/strawberry/image.png',
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/strawberry/image.png',
|
||||
modelSrc: '/generated-match3d-assets/session/profile/items/strawberry/model/model.glb',
|
||||
modelObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/strawberry/model/model.glb',
|
||||
modelFileName: 'strawberry.glb',
|
||||
taskUuid: 'task-strawberry',
|
||||
subscriptionKey: 'sub-strawberry',
|
||||
status: 'model_ready',
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
})}
|
||||
onBack={() => {}}
|
||||
onStartTestRun={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '3D素材' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /草莓/u }));
|
||||
|
||||
expect(screen.getByDisplayValue('草莓')).toBeTruthy();
|
||||
expect(screen.getAllByText('已完成').length).toBeGreaterThan(0);
|
||||
const modelLink = screen.getByRole('link', { name: /strawberry\.glb/u });
|
||||
expect(modelLink.getAttribute('href')).toBe(
|
||||
'https://signed.example.com/generated-match3d-assets/session/profile/items/strawberry/model/model.glb',
|
||||
);
|
||||
});
|
||||
|
||||
test('草稿阶段仅有切割图片时展示图片已就绪,不要求模型文件', () => {
|
||||
render(
|
||||
<Match3DResultView
|
||||
profile={createProfile({
|
||||
clearCount: 3,
|
||||
generatedItemAssets: [
|
||||
{
|
||||
itemId: 'match3d-item-1',
|
||||
itemName: '草莓',
|
||||
imageSrc: '/generated-match3d-assets/session/profile/items/strawberry/image.png',
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/strawberry/image.png',
|
||||
modelSrc: null,
|
||||
modelObjectKey: null,
|
||||
modelFileName: null,
|
||||
taskUuid: null,
|
||||
subscriptionKey: null,
|
||||
status: 'image_ready',
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
})}
|
||||
onBack={() => {}}
|
||||
onStartTestRun={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '3D素材' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /草莓/u }));
|
||||
|
||||
expect(screen.getByDisplayValue('草莓')).toBeTruthy();
|
||||
expect(screen.getAllByText('图片已就绪').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('0 文件')).toBeTruthy();
|
||||
expect(screen.queryByRole('link', { name: /\.glb/u })).toBeNull();
|
||||
});
|
||||
|
||||
test('重进草稿页时从持久化 profile 素材恢复 3D 素材列表', () => {
|
||||
render(
|
||||
<Match3DResultView
|
||||
profile={createProfile({
|
||||
clearCount: 3,
|
||||
generatedItemAssets: [
|
||||
{
|
||||
itemId: 'match3d-item-1',
|
||||
itemName: '草莓',
|
||||
imageSrc:
|
||||
'/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
|
||||
modelSrc: null,
|
||||
modelObjectKey: null,
|
||||
modelFileName: null,
|
||||
taskUuid: null,
|
||||
subscriptionKey: null,
|
||||
status: 'image_ready',
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
itemId: 'match3d-item-2',
|
||||
itemName: '苹果',
|
||||
imageSrc:
|
||||
'/generated-match3d-assets/session/profile/items/match3d-item-2-item/image.png',
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/match3d-item-2-item/image.png',
|
||||
modelSrc: null,
|
||||
modelObjectKey: null,
|
||||
modelFileName: null,
|
||||
taskUuid: null,
|
||||
subscriptionKey: null,
|
||||
status: 'image_ready',
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
})}
|
||||
draft={null}
|
||||
onBack={() => {}}
|
||||
onStartTestRun={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '3D素材' }));
|
||||
|
||||
expect(screen.getByRole('button', { name: /草莓/u })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /苹果/u })).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: /水果核心物件/u })).toBeNull();
|
||||
});
|
||||
|
||||
test('Rodin 图生模型提交前会把 generated 参考图转成 data URL', async () => {
|
||||
vi.mocked(hyper3dService.submitHyper3dImageToModel).mockResolvedValue({
|
||||
ok: true,
|
||||
provider: 'hyper3d-rodin',
|
||||
mode: 'image-to-model',
|
||||
taskUuid: 'task-image',
|
||||
subscriptionKey: 'sub-image',
|
||||
jobUuids: ['job-image'],
|
||||
message: 'submitted',
|
||||
tier: 'Gen-2',
|
||||
});
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
|
||||
render(
|
||||
<Match3DResultView
|
||||
profile={createProfile({
|
||||
clearCount: 3,
|
||||
generatedItemAssets: [
|
||||
{
|
||||
itemId: 'match3d-item-1',
|
||||
itemName: '草莓',
|
||||
imageSrc: '/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
|
||||
modelSrc: null,
|
||||
modelObjectKey: null,
|
||||
modelFileName: null,
|
||||
taskUuid: null,
|
||||
subscriptionKey: null,
|
||||
status: 'image_ready',
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
})}
|
||||
onBack={() => {}}
|
||||
onStartTestRun={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '3D素材' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /草莓/u }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(assetReadUrlService.readAssetBytes).toHaveBeenCalledWith(
|
||||
'/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
|
||||
expect.objectContaining({ expireSeconds: 300 }),
|
||||
);
|
||||
expect(globalThis.fetch).not.toHaveBeenCalled();
|
||||
expect(hyper3dService.submitHyper3dImageToModel).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
imageDataUrls: ['data:image/png;base64,aGVsbG8='],
|
||||
prompt: expect.stringContaining('草莓'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1219,6 +1219,17 @@ function disposeTrayPreview(runtime: TrayPreviewRuntime | null) {
|
||||
runtime.renderer.domElement.remove();
|
||||
}
|
||||
|
||||
export function applyMatch3DRendererCanvasLayout(
|
||||
canvas: HTMLCanvasElement,
|
||||
) {
|
||||
// 中文注释:WebGL 绘图缓冲区会乘设备 DPR,CSS 尺寸必须单独锁住,否则手机端画布会放大溢出。
|
||||
canvas.style.display = 'block';
|
||||
canvas.style.height = '100%';
|
||||
canvas.style.inset = '0';
|
||||
canvas.style.position = 'absolute';
|
||||
canvas.style.width = '100%';
|
||||
}
|
||||
|
||||
function positionTrayPreviewObject(
|
||||
runtime: TrayPreviewRuntime,
|
||||
object: ThreeObject3D,
|
||||
@@ -1280,11 +1291,7 @@ export function Match3DTrayPreviewBoard({
|
||||
});
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.8));
|
||||
renderer.outputColorSpace = three.SRGBColorSpace;
|
||||
renderer.domElement.style.display = 'block';
|
||||
renderer.domElement.style.height = '100%';
|
||||
renderer.domElement.style.inset = '0';
|
||||
renderer.domElement.style.position = 'absolute';
|
||||
renderer.domElement.style.width = '100%';
|
||||
applyMatch3DRendererCanvasLayout(renderer.domElement);
|
||||
container.appendChild(renderer.domElement);
|
||||
const handleContextLost = (event: Event) => {
|
||||
event.preventDefault();
|
||||
@@ -1529,6 +1536,7 @@ export function Match3DPhysicsBoard({
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.8));
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.outputColorSpace = three.SRGBColorSpace;
|
||||
applyMatch3DRendererCanvasLayout(renderer.domElement);
|
||||
container.appendChild(renderer.domElement);
|
||||
const handleContextLost = (event: Event) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
MATCH3D_EXTRUDED_READABLE_SHAPES,
|
||||
MATCH3D_TRAY_MODEL_MIN_RELATIVE_SIZE,
|
||||
MATCH3D_TRAY_MODEL_TARGET_SIZE,
|
||||
applyMatch3DRendererCanvasLayout,
|
||||
buildMatch3DPhysicsEntrySignature,
|
||||
createMatch3DCannonShape,
|
||||
createMatch3DThreeGeometry,
|
||||
@@ -190,6 +191,18 @@ test('3D 模式下备选栏使用共享模型预览层,避免挤占中心棋
|
||||
expect(screen.getByTestId('match3d-tray-model-board')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('3D WebGL 画布锁定 CSS 尺寸,避免高 DPR 手机上溢出中心棋盘', () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
|
||||
applyMatch3DRendererCanvasLayout(canvas);
|
||||
|
||||
expect(canvas.style.position).toBe('absolute');
|
||||
expect(canvas.style.inset).toBe('0');
|
||||
expect(canvas.style.width).toBe('100%');
|
||||
expect(canvas.style.height).toBe('100%');
|
||||
expect(canvas.style.display).toBe('block');
|
||||
});
|
||||
|
||||
test('3D 物理条目签名随 run 和视觉资源变化,避免旧模型复用到新局', () => {
|
||||
const run = startLocalMatch3DRun(10);
|
||||
const item = run.items[0]!;
|
||||
|
||||
@@ -154,6 +154,7 @@ import {
|
||||
} from '../../services/match3d-works';
|
||||
import {
|
||||
buildBigFishGenerationAnchorEntries,
|
||||
buildMatch3DGenerationAnchorEntries,
|
||||
buildMiniGameDraftGenerationProgress,
|
||||
buildPuzzleGenerationAnchorEntries,
|
||||
buildSquareHoleGenerationAnchorEntries,
|
||||
@@ -284,9 +285,13 @@ import { useRpgCreationAgentOperationPolling } from '../rpg-entry/useRpgCreation
|
||||
import { useRpgCreationEnterWorld } from '../rpg-entry/useRpgCreationEnterWorld';
|
||||
import { useRpgCreationResultAutosave } from '../rpg-entry/useRpgCreationResultAutosave';
|
||||
import { useRpgCreationSessionController } from '../rpg-entry/useRpgCreationSessionController';
|
||||
import {
|
||||
buildVisualNovelEntryGenerationAnchorEntries,
|
||||
buildVisualNovelEntryGenerationProgress,
|
||||
type VisualNovelEntryFormPayload,
|
||||
} from '../visual-novel-creation/VisualNovelAgentWorkspace';
|
||||
import { createMockVisualNovelRunFromDraft } from '../visual-novel-runtime/visualNovelMockData';
|
||||
import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal';
|
||||
import { PlatformFeedbackView } from './PlatformFeedbackView';
|
||||
import type { PlatformCreationTypeId } from './platformEntryCreationTypes';
|
||||
import {
|
||||
getVisiblePlatformCreationTypes,
|
||||
@@ -302,6 +307,7 @@ import {
|
||||
} from './platformEntryShared';
|
||||
import type { PlatformEntryFlowShellProps } from './platformEntryTypes';
|
||||
import { PlatformEntryWorldDetailView } from './PlatformEntryWorldDetailView';
|
||||
import { PlatformFeedbackView } from './PlatformFeedbackView';
|
||||
import { PlatformWorkDetailView } from './PlatformWorkDetailView';
|
||||
import { usePlatformCreationAgentFlowController } from './usePlatformCreationAgentFlowController';
|
||||
import { usePlatformEntryBootstrap } from './usePlatformEntryBootstrap';
|
||||
@@ -349,6 +355,7 @@ type VisualNovelRuntimeReturnStage =
|
||||
| 'visual-novel-gallery-detail'
|
||||
| 'work-detail'
|
||||
| 'platform';
|
||||
type VisualNovelEntryGenerationPhase = 'generating' | 'ready' | 'failed';
|
||||
|
||||
type PuzzleSaveArchiveState = {
|
||||
runtimeKind?: unknown;
|
||||
@@ -591,6 +598,7 @@ function buildMatch3DProfileFromSession(
|
||||
updatedAt: now,
|
||||
publishedAt: null,
|
||||
publishReady: Boolean(draft.publishReady),
|
||||
generatedItemAssets: draft.generatedItemAssets,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1513,6 +1521,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
const [match3dRuntimeReturnStage, setMatch3DRuntimeReturnStage] = useState<
|
||||
'match3d-result' | 'work-detail'
|
||||
>('match3d-result');
|
||||
const [match3dFormDraftPayload, setMatch3DFormDraftPayload] =
|
||||
useState<CreateMatch3DSessionRequest | null>(null);
|
||||
const [match3dGenerationState, setMatch3DGenerationState] =
|
||||
useState<MiniGameDraftGenerationState | null>(null);
|
||||
const [isMatch3DLoadingLibrary, setIsMatch3DLoadingLibrary] = useState(false);
|
||||
const [squareHoleWorks, setSquareHoleWorks] = useState<
|
||||
SquareHoleWorkSummary[]
|
||||
@@ -1597,10 +1609,12 @@ export function PlatformEntryFlowShellImpl({
|
||||
);
|
||||
const [puzzleGenerationState, setPuzzleGenerationState] =
|
||||
useState<MiniGameDraftGenerationState | null>(null);
|
||||
const [puzzleGenerationProgressNowMs, setPuzzleGenerationProgressNowMs] =
|
||||
const [miniGameGenerationProgressNowMs, setMiniGameGenerationProgressNowMs] =
|
||||
useState(() => Date.now());
|
||||
const [puzzleFormDraftPayload, setPuzzleFormDraftPayload] =
|
||||
useState<CreatePuzzleAgentSessionRequest | null>(null);
|
||||
const [activeCreationFormType, setActiveCreationFormType] =
|
||||
useState<PlatformCreationTypeId>('puzzle');
|
||||
const [puzzleOnboardingPrompt, setPuzzleOnboardingPrompt] = useState('');
|
||||
const [puzzleOnboardingPhase, setPuzzleOnboardingPhase] =
|
||||
useState<PuzzleOnboardingPhase>('input');
|
||||
@@ -1640,6 +1654,12 @@ export function PlatformEntryFlowShellImpl({
|
||||
useState<VisualNovelRunSnapshot | null>(null);
|
||||
const [visualNovelRuntimeReturnStage, setVisualNovelRuntimeReturnStage] =
|
||||
useState<VisualNovelRuntimeReturnStage>('visual-novel-result');
|
||||
const [visualNovelFormDraftPayload, setVisualNovelFormDraftPayload] =
|
||||
useState<VisualNovelEntryFormPayload | null>(null);
|
||||
const [visualNovelGenerationStartedAtMs, setVisualNovelGenerationStartedAtMs] =
|
||||
useState<number | null>(null);
|
||||
const [visualNovelGenerationPhase, setVisualNovelGenerationPhase] =
|
||||
useState<VisualNovelEntryGenerationPhase>('generating');
|
||||
const [isVisualNovelLoadingLibrary, setIsVisualNovelLoadingLibrary] =
|
||||
useState(false);
|
||||
const [isPuzzleNextLevelGenerating, setIsPuzzleNextLevelGenerating] =
|
||||
@@ -2191,23 +2211,38 @@ export function PlatformEntryFlowShellImpl({
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const shouldTickPuzzleProgress =
|
||||
selectionStage === 'puzzle-generating' &&
|
||||
puzzleGenerationState != null &&
|
||||
puzzleGenerationState.phase !== 'ready' &&
|
||||
puzzleGenerationState.phase !== 'failed';
|
||||
const activeGenerationState =
|
||||
selectionStage === 'puzzle-generating'
|
||||
? puzzleGenerationState
|
||||
: selectionStage === 'match3d-generating'
|
||||
? match3dGenerationState
|
||||
: null;
|
||||
const shouldTickProgress =
|
||||
selectionStage === 'visual-novel-generating'
|
||||
? visualNovelGenerationStartedAtMs != null &&
|
||||
visualNovelGenerationPhase !== 'ready' &&
|
||||
visualNovelGenerationPhase !== 'failed'
|
||||
: activeGenerationState != null &&
|
||||
activeGenerationState.phase !== 'ready' &&
|
||||
activeGenerationState.phase !== 'failed';
|
||||
|
||||
if (!shouldTickPuzzleProgress) {
|
||||
if (!shouldTickProgress) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
setPuzzleGenerationProgressNowMs(Date.now());
|
||||
setMiniGameGenerationProgressNowMs(Date.now());
|
||||
const timerId = window.setInterval(() => {
|
||||
setPuzzleGenerationProgressNowMs(Date.now());
|
||||
setMiniGameGenerationProgressNowMs(Date.now());
|
||||
}, 500);
|
||||
|
||||
return () => window.clearInterval(timerId);
|
||||
}, [puzzleGenerationState, selectionStage]);
|
||||
}, [
|
||||
match3dGenerationState,
|
||||
puzzleGenerationState,
|
||||
selectionStage,
|
||||
visualNovelGenerationPhase,
|
||||
visualNovelGenerationStartedAtMs,
|
||||
]);
|
||||
|
||||
const runProtectedAction = useCallback(
|
||||
(action: () => void) => {
|
||||
@@ -2514,6 +2549,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
enterCreateTab,
|
||||
setSelectionStage,
|
||||
onSessionOpened: () => {
|
||||
setActiveCreationFormType('match3d');
|
||||
setShowCreationTypeModal(false);
|
||||
},
|
||||
onActionComplete: async ({ payload, response, setSession }) => {
|
||||
@@ -2521,6 +2557,18 @@ export function PlatformEntryFlowShellImpl({
|
||||
if (payload.action !== 'match3d_compile_draft') {
|
||||
return;
|
||||
}
|
||||
setMatch3DGenerationState((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
phase: 'ready',
|
||||
completedAssetCount:
|
||||
response.session.draft?.generatedItemAssets?.length ?? 3,
|
||||
totalAssetCount:
|
||||
response.session.draft?.generatedItemAssets?.length ?? 3,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
|
||||
const profileId = response.session.draft?.profileId;
|
||||
if (!profileId) {
|
||||
@@ -2530,12 +2578,38 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
try {
|
||||
const { item } = await getMatch3DWorkDetail(profileId);
|
||||
setMatch3DProfile(item);
|
||||
setMatch3DProfile({
|
||||
...item,
|
||||
generatedItemAssets:
|
||||
response.session.draft?.generatedItemAssets ??
|
||||
item.generatedItemAssets,
|
||||
});
|
||||
await refreshMatch3DShelf().catch(() => undefined);
|
||||
} catch {
|
||||
setMatch3DProfile(buildMatch3DProfileFromSession(response.session));
|
||||
}
|
||||
},
|
||||
beforeExecuteAction: ({ payload }) => {
|
||||
if (payload.action !== 'match3d_compile_draft') {
|
||||
return;
|
||||
}
|
||||
setSelectionStage('match3d-generating');
|
||||
setMatch3DGenerationState(createMiniGameDraftGenerationState('match3d'));
|
||||
},
|
||||
onActionError: ({ payload, errorMessage }) => {
|
||||
if (payload.action !== 'match3d_compile_draft') {
|
||||
return;
|
||||
}
|
||||
setMatch3DGenerationState((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
phase: 'failed',
|
||||
error: errorMessage,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const squareHoleFlow = usePlatformCreationAgentFlowController<
|
||||
@@ -2723,6 +2797,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
enterCreateTab,
|
||||
setSelectionStage,
|
||||
onSessionOpened: () => {
|
||||
setActiveCreationFormType('puzzle');
|
||||
sessionController.setCreationTypeError(null);
|
||||
setPuzzleCreationError(null);
|
||||
setShowCreationTypeModal(false);
|
||||
@@ -2873,7 +2948,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
const setMatch3DError = match3dFlow.setError;
|
||||
match3DErrorSetterRef.current = setMatch3DError;
|
||||
const isMatch3DBusy = match3dFlow.isBusy;
|
||||
const streamingMatch3DReplyText = match3dFlow.streamingReplyText;
|
||||
const setStreamingMatch3DReplyText = match3dFlow.setStreamingReplyText;
|
||||
const isStreamingMatch3DReply = match3dFlow.isStreamingReply;
|
||||
const setIsStreamingMatch3DReply = match3dFlow.setIsStreamingReply;
|
||||
@@ -2903,7 +2977,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
visualNovelErrorSetterRef.current = setVisualNovelError;
|
||||
const isVisualNovelBusy = visualNovelFlow.isBusy;
|
||||
const setIsVisualNovelBusy = visualNovelFlow.setIsBusy;
|
||||
const visualNovelStreamingReplyText = visualNovelFlow.streamingReplyText;
|
||||
const isVisualNovelStreamingReply = visualNovelFlow.isStreamingReply;
|
||||
const resetRpgSessionViewState = sessionController.resetSessionViewState;
|
||||
const setRpgGeneratedCustomWorldProfile =
|
||||
@@ -2922,24 +2995,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
await bigFishFlow.openWorkspace();
|
||||
}, [bigFishFlow]);
|
||||
|
||||
const openMatch3DAgentWorkspace = useCallback(async () => {
|
||||
setMatch3DSession(null);
|
||||
setMatch3DProfile(null);
|
||||
setMatch3DRun(null);
|
||||
setMatch3DError(null);
|
||||
setStreamingMatch3DReplyText('');
|
||||
setIsStreamingMatch3DReply(false);
|
||||
await match3dFlow.openWorkspace();
|
||||
}, [
|
||||
match3dFlow,
|
||||
setIsStreamingMatch3DReply,
|
||||
setMatch3DError,
|
||||
setMatch3DProfile,
|
||||
setMatch3DRun,
|
||||
setMatch3DSession,
|
||||
setStreamingMatch3DReplyText,
|
||||
]);
|
||||
|
||||
const openSquareHoleAgentWorkspace = useCallback(async () => {
|
||||
setSquareHoleSession(null);
|
||||
setSquareHoleProfile(null);
|
||||
@@ -2960,30 +3015,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
squareHoleFlow,
|
||||
]);
|
||||
|
||||
const openPuzzleAgentWorkspace = useCallback(async () => {
|
||||
setPuzzleRun(null);
|
||||
setPuzzleRuntimeAuthMode('default');
|
||||
setPuzzleOperation(null);
|
||||
setPuzzleGenerationState(null);
|
||||
setPuzzleFormDraftPayload(null);
|
||||
sessionController.setCreationTypeError(null);
|
||||
setPuzzleCreationError(null);
|
||||
const nextSession = await puzzleFlow.openWorkspace({});
|
||||
if (nextSession) {
|
||||
void refreshPuzzleShelf();
|
||||
}
|
||||
}, [puzzleFlow, refreshPuzzleShelf, sessionController]);
|
||||
|
||||
const openVisualNovelAgentWorkspace = useCallback(() => {
|
||||
setVisualNovelWork(null);
|
||||
setVisualNovelRun(null);
|
||||
setVisualNovelRuntimeReturnStage('visual-novel-result');
|
||||
visualNovelFlow.resetTransientState();
|
||||
enterCreateTab();
|
||||
setShowCreationTypeModal(false);
|
||||
setSelectionStage('visual-novel-agent-workspace');
|
||||
}, [enterCreateTab, setSelectionStage, visualNovelFlow]);
|
||||
|
||||
const leaveCreativeAgentWorkspace = useCallback(() => {
|
||||
const sessionId = creativeAgentSession?.sessionId?.trim();
|
||||
if (sessionId && creativeAgentSession?.stage !== 'target_ready') {
|
||||
@@ -3058,6 +3089,85 @@ export function PlatformEntryFlowShellImpl({
|
||||
[puzzleFlow],
|
||||
);
|
||||
|
||||
const createMatch3DDraftFromForm = useCallback(
|
||||
async (payload: CreateMatch3DSessionRequest) => {
|
||||
setMatch3DFormDraftPayload(payload);
|
||||
setMatch3DGenerationState(null);
|
||||
setMatch3DSession(null);
|
||||
setMatch3DProfile(null);
|
||||
setMatch3DRun(null);
|
||||
setMatch3DError(null);
|
||||
setStreamingMatch3DReplyText('');
|
||||
setIsStreamingMatch3DReply(false);
|
||||
|
||||
const nextSession = await match3dFlow.openWorkspace(payload);
|
||||
if (!nextSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
await match3dFlow.executeAction(
|
||||
{ action: 'match3d_compile_draft' },
|
||||
nextSession,
|
||||
);
|
||||
},
|
||||
[
|
||||
match3dFlow,
|
||||
setIsStreamingMatch3DReply,
|
||||
setMatch3DError,
|
||||
setMatch3DProfile,
|
||||
setMatch3DRun,
|
||||
setMatch3DSession,
|
||||
setStreamingMatch3DReplyText,
|
||||
],
|
||||
);
|
||||
|
||||
const createVisualNovelDraftFromForm = useCallback(
|
||||
async (payload: VisualNovelEntryFormPayload) => {
|
||||
setVisualNovelFormDraftPayload(payload);
|
||||
setVisualNovelGenerationStartedAtMs(Date.now());
|
||||
setVisualNovelGenerationPhase('generating');
|
||||
setVisualNovelWork(null);
|
||||
setVisualNovelRun(null);
|
||||
setVisualNovelRuntimeReturnStage('visual-novel-result');
|
||||
setVisualNovelError(null);
|
||||
setIsVisualNovelBusy(true);
|
||||
setSelectionStage('visual-novel-generating');
|
||||
|
||||
try {
|
||||
const createResponse = await createVisualNovelSession({
|
||||
sourceMode: payload.sourceMode,
|
||||
seedText: payload.seedText,
|
||||
sourceAssetIds: payload.sourceAssetIds,
|
||||
});
|
||||
setVisualNovelSession(createResponse.session);
|
||||
const nextSession = await streamVisualNovelMessage(
|
||||
createResponse.session.sessionId,
|
||||
{
|
||||
clientMessageId: `visual-novel-entry-${Date.now().toString(36)}`,
|
||||
text: payload.seedText,
|
||||
},
|
||||
);
|
||||
setVisualNovelSession(nextSession);
|
||||
setVisualNovelGenerationPhase('ready');
|
||||
setSelectionStage('visual-novel-result');
|
||||
} catch (error) {
|
||||
setVisualNovelGenerationPhase('failed');
|
||||
setVisualNovelError(
|
||||
resolvePuzzleErrorMessage(error, '生成视觉小说草稿失败。'),
|
||||
);
|
||||
} finally {
|
||||
setIsVisualNovelBusy(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
resolvePuzzleErrorMessage,
|
||||
setIsVisualNovelBusy,
|
||||
setSelectionStage,
|
||||
setVisualNovelError,
|
||||
setVisualNovelSession,
|
||||
],
|
||||
);
|
||||
|
||||
const savePuzzleFormDraft = useCallback(
|
||||
async (payload: CreatePuzzleAgentSessionRequest) => {
|
||||
const session = puzzleFlow.session;
|
||||
@@ -3118,6 +3228,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
setBigFishError(null);
|
||||
setMatch3DSession(null);
|
||||
setMatch3DProfile(null);
|
||||
setMatch3DFormDraftPayload(null);
|
||||
setActiveCreationFormType('puzzle');
|
||||
setMatch3DWorks([]);
|
||||
setMatch3DGalleryEntries([]);
|
||||
setMatch3DRun(null);
|
||||
@@ -3160,6 +3272,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
setVisualNovelGalleryEntries([]);
|
||||
setVisualNovelRun(null);
|
||||
setVisualNovelRuntimeReturnStage('visual-novel-result');
|
||||
setVisualNovelFormDraftPayload(null);
|
||||
setVisualNovelGenerationStartedAtMs(null);
|
||||
setVisualNovelGenerationPhase('generating');
|
||||
setVisualNovelError(null);
|
||||
setDeletingCreationWorkId(null);
|
||||
setClaimingPuzzlePointIncentiveProfileId(null);
|
||||
@@ -3189,6 +3304,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
resetAutoSaveTrackingToIdle,
|
||||
resetRpgSessionViewState,
|
||||
selectionStage,
|
||||
setActiveCreationFormType,
|
||||
setBigFishError,
|
||||
setIsStreamingMatch3DReply,
|
||||
setIsStreamingSquareHoleReply,
|
||||
@@ -3231,9 +3347,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
}
|
||||
|
||||
if (type === 'match3d') {
|
||||
runProtectedAction(() => {
|
||||
void openMatch3DAgentWorkspace();
|
||||
});
|
||||
enterCreateTab();
|
||||
setShowCreationTypeModal(false);
|
||||
setActiveCreationFormType('match3d');
|
||||
setMatch3DError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3245,28 +3362,34 @@ export function PlatformEntryFlowShellImpl({
|
||||
}
|
||||
|
||||
if (type === 'puzzle') {
|
||||
runProtectedAction(() => {
|
||||
void openPuzzleAgentWorkspace();
|
||||
});
|
||||
enterCreateTab();
|
||||
setShowCreationTypeModal(false);
|
||||
setActiveCreationFormType('puzzle');
|
||||
setPuzzleCreationError(null);
|
||||
setPuzzleError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'visual-novel') {
|
||||
runProtectedAction(() => {
|
||||
openVisualNovelAgentWorkspace();
|
||||
});
|
||||
enterCreateTab();
|
||||
setShowCreationTypeModal(false);
|
||||
setActiveCreationFormType('visual-novel');
|
||||
setVisualNovelError(null);
|
||||
return;
|
||||
}
|
||||
},
|
||||
[
|
||||
openBigFishAgentWorkspace,
|
||||
openMatch3DAgentWorkspace,
|
||||
openPuzzleAgentWorkspace,
|
||||
openSquareHoleAgentWorkspace,
|
||||
openVisualNovelAgentWorkspace,
|
||||
enterCreateTab,
|
||||
openSquareHoleAgentWorkspace,
|
||||
prepareCreationLaunch,
|
||||
runProtectedAction,
|
||||
sessionController,
|
||||
setActiveCreationFormType,
|
||||
setMatch3DError,
|
||||
setPuzzleCreationError,
|
||||
setPuzzleError,
|
||||
setVisualNovelError,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -3281,9 +3404,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
const leaveMatch3DFlow = useCallback(() => {
|
||||
setMatch3DRun(null);
|
||||
setMatch3DFormDraftPayload(null);
|
||||
setMatch3DGenerationState(null);
|
||||
setMatch3DRuntimeReturnStage('match3d-result');
|
||||
match3dFlow.leaveFlow();
|
||||
}, [match3dFlow]);
|
||||
}, [match3dFlow, setMatch3DFormDraftPayload]);
|
||||
|
||||
const leaveSquareHoleFlow = useCallback(() => {
|
||||
setSquareHoleRun(null);
|
||||
@@ -3307,20 +3432,12 @@ export function PlatformEntryFlowShellImpl({
|
||||
setVisualNovelWork(null);
|
||||
setVisualNovelRun(null);
|
||||
setVisualNovelRuntimeReturnStage('visual-novel-result');
|
||||
setVisualNovelFormDraftPayload(null);
|
||||
setVisualNovelGenerationStartedAtMs(null);
|
||||
setVisualNovelGenerationPhase('generating');
|
||||
visualNovelFlow.leaveFlow();
|
||||
}, [visualNovelFlow]);
|
||||
|
||||
const openVisualNovelResult = useCallback(
|
||||
(session: VisualNovelAgentSessionSnapshot) => {
|
||||
setVisualNovelSession(session);
|
||||
setVisualNovelWork(null);
|
||||
setVisualNovelRun(null);
|
||||
setVisualNovelRuntimeReturnStage('visual-novel-result');
|
||||
setSelectionStage('visual-novel-result');
|
||||
},
|
||||
[setSelectionStage, setVisualNovelSession],
|
||||
);
|
||||
|
||||
const saveVisualNovelDraft = useCallback(
|
||||
async (draft: VisualNovelResultDraft) => {
|
||||
const currentSession = visualNovelSession;
|
||||
@@ -3716,8 +3833,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
const submitBigFishMessage = bigFishFlow.submitMessage;
|
||||
|
||||
const submitMatch3DMessage = match3dFlow.submitMessage;
|
||||
|
||||
const submitSquareHoleMessage = squareHoleFlow.submitMessage;
|
||||
|
||||
const submitPuzzleMessage = puzzleFlow.submitMessage;
|
||||
@@ -3728,6 +3843,21 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
const executeSquareHoleAction = squareHoleFlow.executeAction;
|
||||
|
||||
const retryMatch3DDraftGeneration = useCallback(() => {
|
||||
if (match3dFormDraftPayload) {
|
||||
void createMatch3DDraftFromForm(match3dFormDraftPayload);
|
||||
return;
|
||||
}
|
||||
|
||||
void executeMatch3DAction({
|
||||
action: 'match3d_compile_draft',
|
||||
});
|
||||
}, [
|
||||
createMatch3DDraftFromForm,
|
||||
executeMatch3DAction,
|
||||
match3dFormDraftPayload,
|
||||
]);
|
||||
|
||||
const retrySquareHoleAssetGeneration = useCallback(() => {
|
||||
const session = squareHoleSession;
|
||||
if (!session?.draft?.profileId) {
|
||||
@@ -3744,6 +3874,35 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
const executePuzzleAction = puzzleFlow.executeAction;
|
||||
|
||||
const executePuzzleBackgroundAction = useCallback(
|
||||
async (payload: PuzzleAgentActionRequest) => {
|
||||
const targetSession = puzzleFlow.session;
|
||||
if (!targetSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formPayload = buildPuzzleFormPayloadFromAction(payload);
|
||||
if (formPayload) {
|
||||
setPuzzleFormDraftPayload(formPayload);
|
||||
}
|
||||
|
||||
setPuzzleError(null);
|
||||
try {
|
||||
const response = await executePuzzleAgentAction(
|
||||
targetSession.sessionId,
|
||||
payload,
|
||||
);
|
||||
setPuzzleOperation(response.operation);
|
||||
puzzleFlow.setSession(response.session);
|
||||
} catch (error) {
|
||||
setPuzzleError(
|
||||
resolvePuzzleErrorMessage(error, '执行拼图操作失败。'),
|
||||
);
|
||||
}
|
||||
},
|
||||
[puzzleFlow, resolvePuzzleErrorMessage, setPuzzleError],
|
||||
);
|
||||
|
||||
const retryPuzzleDraftGeneration = useCallback(() => {
|
||||
if (puzzleFormDraftPayload) {
|
||||
void createPuzzleDraftFromForm(puzzleFormDraftPayload);
|
||||
@@ -3755,6 +3914,19 @@ export function PlatformEntryFlowShellImpl({
|
||||
);
|
||||
}, [createPuzzleDraftFromForm, executePuzzleAction, puzzleFormDraftPayload]);
|
||||
|
||||
const retryVisualNovelDraftGeneration = useCallback(() => {
|
||||
if (!visualNovelFormDraftPayload) {
|
||||
setSelectionStage('visual-novel-agent-workspace');
|
||||
return;
|
||||
}
|
||||
|
||||
void createVisualNovelDraftFromForm(visualNovelFormDraftPayload);
|
||||
}, [
|
||||
createVisualNovelDraftFromForm,
|
||||
setSelectionStage,
|
||||
visualNovelFormDraftPayload,
|
||||
]);
|
||||
|
||||
const executePuzzleWorkspaceAction = useCallback(
|
||||
(payload: PuzzleAgentActionRequest) => {
|
||||
if (
|
||||
@@ -3768,9 +3940,19 @@ export function PlatformEntryFlowShellImpl({
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.action === 'generate_puzzle_images') {
|
||||
void executePuzzleBackgroundAction(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
void executePuzzleAction(payload);
|
||||
},
|
||||
[createPuzzleDraftFromForm, executePuzzleAction, puzzleFlow.session],
|
||||
[
|
||||
createPuzzleDraftFromForm,
|
||||
executePuzzleAction,
|
||||
executePuzzleBackgroundAction,
|
||||
puzzleFlow.session,
|
||||
],
|
||||
);
|
||||
|
||||
const openCreativeAgentTarget = useCallback(async () => {
|
||||
@@ -5098,6 +5280,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
: '删除后会从你的作品列表中移除。',
|
||||
run: () => {
|
||||
setDeletingCreationWorkId(work.workId);
|
||||
setMatch3DFormDraftPayload(null);
|
||||
setMatch3DError(null);
|
||||
|
||||
void deleteMatch3DWork(work.profileId)
|
||||
@@ -5121,6 +5304,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
refreshMatch3DGallery,
|
||||
requestDeleteCreationWork,
|
||||
resolveMatch3DErrorMessage,
|
||||
setMatch3DFormDraftPayload,
|
||||
setMatch3DError,
|
||||
],
|
||||
);
|
||||
@@ -5805,6 +5989,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
return;
|
||||
}
|
||||
|
||||
setMatch3DFormDraftPayload(null);
|
||||
|
||||
try {
|
||||
const { item: profile } = await getMatch3DWorkDetail(item.profileId);
|
||||
setMatch3DProfile(profile);
|
||||
@@ -5820,6 +6006,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
openPublicWorkDetail,
|
||||
refreshMatch3DShelf,
|
||||
resolveMatch3DErrorMessage,
|
||||
setMatch3DFormDraftPayload,
|
||||
setMatch3DError,
|
||||
],
|
||||
);
|
||||
@@ -7424,7 +7611,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
aria-label="选择模板"
|
||||
>
|
||||
{getVisiblePlatformCreationTypes().map((item) => {
|
||||
const selected = item.id === 'puzzle';
|
||||
const selected = item.id === activeCreationFormType;
|
||||
const disabled =
|
||||
item.locked ||
|
||||
sessionController.isCreatingAgentSession ||
|
||||
@@ -7446,7 +7633,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
aria-selected={selected}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (item.id === 'puzzle') {
|
||||
if (item.id === activeCreationFormType) {
|
||||
return;
|
||||
}
|
||||
handleCreationHubCreateType(item.id);
|
||||
@@ -7496,31 +7683,76 @@ export function PlatformEntryFlowShellImpl({
|
||||
</div>
|
||||
|
||||
<div className="mt-3 min-h-0 flex-1 overflow-hidden">
|
||||
<Suspense fallback={<LazyPanelFallback label="正在加载拼图创作..." />}>
|
||||
<PuzzleAgentWorkspace
|
||||
session={puzzleSession}
|
||||
isBusy={isPuzzleBusy || isStreamingPuzzleReply}
|
||||
error={puzzleError}
|
||||
onBack={leavePuzzleFlow}
|
||||
onSubmitMessage={(payload) => {
|
||||
void submitPuzzleMessage(payload);
|
||||
}}
|
||||
onExecuteAction={(payload) => {
|
||||
executePuzzleWorkspaceAction(payload);
|
||||
}}
|
||||
initialFormPayload={puzzleFormDraftPayload}
|
||||
onCreateFromForm={(payload) => {
|
||||
runProtectedAction(() => {
|
||||
void createPuzzleDraftFromForm(payload);
|
||||
});
|
||||
}}
|
||||
onAutoSaveForm={(payload) => {
|
||||
void savePuzzleFormDraft(payload);
|
||||
}}
|
||||
showBackButton={false}
|
||||
title={null}
|
||||
/>
|
||||
</Suspense>
|
||||
{activeCreationFormType === 'match3d' ? (
|
||||
<Suspense
|
||||
fallback={
|
||||
<LazyPanelFallback label="正在加载抓大鹅创作..." />
|
||||
}
|
||||
>
|
||||
<Match3DAgentWorkspace
|
||||
session={match3dSession}
|
||||
isBusy={isMatch3DBusy || isStreamingMatch3DReply}
|
||||
error={match3dError}
|
||||
onBack={leaveMatch3DFlow}
|
||||
onExecuteAction={(payload) => {
|
||||
void executeMatch3DAction(payload);
|
||||
}}
|
||||
initialFormPayload={match3dFormDraftPayload}
|
||||
onCreateFromForm={(payload) => {
|
||||
runProtectedAction(() => {
|
||||
void createMatch3DDraftFromForm(payload);
|
||||
});
|
||||
}}
|
||||
showBackButton={false}
|
||||
title={null}
|
||||
/>
|
||||
</Suspense>
|
||||
) : activeCreationFormType === 'visual-novel' ? (
|
||||
<Suspense
|
||||
fallback={<LazyPanelFallback label="正在加载视觉小说创作..." />}
|
||||
>
|
||||
<VisualNovelAgentWorkspace
|
||||
session={null}
|
||||
isBusy={isVisualNovelBusy || isVisualNovelStreamingReply}
|
||||
error={visualNovelError}
|
||||
onBack={leaveVisualNovelFlow}
|
||||
initialFormPayload={visualNovelFormDraftPayload}
|
||||
onCreateFromForm={(payload) => {
|
||||
runProtectedAction(() => {
|
||||
void createVisualNovelDraftFromForm(payload);
|
||||
});
|
||||
}}
|
||||
showBackButton={false}
|
||||
title={null}
|
||||
/>
|
||||
</Suspense>
|
||||
) : (
|
||||
<Suspense fallback={<LazyPanelFallback label="正在加载拼图创作..." />}>
|
||||
<PuzzleAgentWorkspace
|
||||
session={puzzleSession}
|
||||
isBusy={isPuzzleBusy || isStreamingPuzzleReply}
|
||||
error={puzzleError}
|
||||
onBack={leavePuzzleFlow}
|
||||
onSubmitMessage={(payload) => {
|
||||
void submitPuzzleMessage(payload);
|
||||
}}
|
||||
onExecuteAction={(payload) => {
|
||||
executePuzzleWorkspaceAction(payload);
|
||||
}}
|
||||
initialFormPayload={puzzleFormDraftPayload}
|
||||
onCreateFromForm={(payload) => {
|
||||
runProtectedAction(() => {
|
||||
void createPuzzleDraftFromForm(payload);
|
||||
});
|
||||
}}
|
||||
onAutoSaveForm={(payload) => {
|
||||
void savePuzzleFormDraft(payload);
|
||||
}}
|
||||
showBackButton={false}
|
||||
title={null}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -7601,6 +7833,12 @@ export function PlatformEntryFlowShellImpl({
|
||||
onSelectPreviousRecommendEntry={() =>
|
||||
selectAdjacentRecommendRuntimeEntry(-1)
|
||||
}
|
||||
onLikeRecommendEntry={(entry) => {
|
||||
likePublicWork(entry);
|
||||
}}
|
||||
onRemixRecommendEntry={(entry) => {
|
||||
remixPublicWork(entry);
|
||||
}}
|
||||
onOpenLibraryDetail={(entry) => {
|
||||
runProtectedAction(() => {
|
||||
void detailNavigation.openLibraryDetail(entry);
|
||||
@@ -7984,7 +8222,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{selectionStage === 'match3d-agent-workspace' && (
|
||||
{selectionStage === 'match3d-agent-workspace' && match3dSession && (
|
||||
<motion.div
|
||||
key="match3d-agent-workspace"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
@@ -7999,14 +8237,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
>
|
||||
<Match3DAgentWorkspace
|
||||
session={match3dSession}
|
||||
streamingReplyText={streamingMatch3DReplyText}
|
||||
isStreamingReply={isStreamingMatch3DReply}
|
||||
isBusy={isMatch3DBusy || isStreamingMatch3DReply}
|
||||
error={match3dError}
|
||||
onBack={leaveMatch3DFlow}
|
||||
onSubmitMessage={(payload) => {
|
||||
void submitMatch3DMessage(payload);
|
||||
}}
|
||||
onExecuteAction={(payload) => {
|
||||
void executeMatch3DAction(payload);
|
||||
}}
|
||||
@@ -8015,6 +8248,52 @@ export function PlatformEntryFlowShellImpl({
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{selectionStage === 'match3d-generating' && (
|
||||
<motion.div
|
||||
key="match3d-generating"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
>
|
||||
<Suspense
|
||||
fallback={<LazyPanelFallback label="正在加载抓大鹅生成面板..." />}
|
||||
>
|
||||
<CustomWorldGenerationView
|
||||
settingText={
|
||||
match3dSession?.lastAssistantReply ??
|
||||
'正在生成本局抓大鹅物品素材。'
|
||||
}
|
||||
anchorEntries={buildMatch3DGenerationAnchorEntries(
|
||||
match3dSession,
|
||||
match3dFormDraftPayload,
|
||||
)}
|
||||
progress={buildMiniGameDraftGenerationProgress(
|
||||
match3dGenerationState,
|
||||
miniGameGenerationProgressNowMs,
|
||||
)}
|
||||
isGenerating={isMatch3DBusy}
|
||||
error={match3dError}
|
||||
onBack={leaveMatch3DFlow}
|
||||
onEditSetting={() => {
|
||||
setSelectionStage('match3d-agent-workspace');
|
||||
}}
|
||||
onRetry={retryMatch3DDraftGeneration}
|
||||
onInterrupt={undefined}
|
||||
backLabel="返回创作中心"
|
||||
settingActionLabel={null}
|
||||
retryLabel="重新生成草稿"
|
||||
settingTitle="当前抓大鹅信息"
|
||||
settingDescription={null}
|
||||
progressTitle="抓大鹅草稿生成进度"
|
||||
activeBadgeLabel="素材生成中"
|
||||
pausedBadgeLabel="素材生成已暂停"
|
||||
idleBadgeLabel="等待返回工作区"
|
||||
/>
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{selectionStage === 'match3d-result' && match3dSession?.draft && (
|
||||
<motion.div
|
||||
key="match3d-result"
|
||||
@@ -8467,7 +8746,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
)}
|
||||
progress={buildMiniGameDraftGenerationProgress(
|
||||
puzzleGenerationState,
|
||||
puzzleGenerationProgressNowMs,
|
||||
miniGameGenerationProgressNowMs,
|
||||
)}
|
||||
isGenerating={isPuzzleBusy}
|
||||
error={puzzleError}
|
||||
@@ -8514,7 +8793,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
error={puzzleError}
|
||||
onBack={leavePuzzleFlow}
|
||||
onExecuteAction={(payload) => {
|
||||
void executePuzzleAction(payload);
|
||||
executePuzzleWorkspaceAction(payload);
|
||||
}}
|
||||
onStartTestRun={startPuzzleTestRunFromDraft}
|
||||
creativeDraftEdit={
|
||||
@@ -8546,25 +8825,67 @@ export function PlatformEntryFlowShellImpl({
|
||||
session={visualNovelSession}
|
||||
isBusy={isVisualNovelBusy || isVisualNovelStreamingReply}
|
||||
error={visualNovelError}
|
||||
streamingReplyText={visualNovelStreamingReplyText}
|
||||
onBack={leaveVisualNovelFlow}
|
||||
onCreateSession={(payload) => {
|
||||
void visualNovelFlow.openWorkspace(payload);
|
||||
initialFormPayload={visualNovelFormDraftPayload}
|
||||
onCreateFromForm={(payload) => {
|
||||
void createVisualNovelDraftFromForm(payload);
|
||||
}}
|
||||
onSubmitMessage={(payload) => {
|
||||
void visualNovelFlow.submitMessage(payload);
|
||||
/>
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{selectionStage === 'visual-novel-generating' && (
|
||||
<motion.div
|
||||
key="visual-novel-generating"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
>
|
||||
<Suspense
|
||||
fallback={<LazyPanelFallback label="正在加载视觉小说生成面板..." />}
|
||||
>
|
||||
<CustomWorldGenerationView
|
||||
settingText={
|
||||
visualNovelFormDraftPayload?.seedText ??
|
||||
visualNovelSession?.messages.find(
|
||||
(message) => message.role === 'user',
|
||||
)?.text ??
|
||||
'正在整理当前视觉小说草稿。'
|
||||
}
|
||||
anchorEntries={buildVisualNovelEntryGenerationAnchorEntries(
|
||||
visualNovelFormDraftPayload,
|
||||
)}
|
||||
progress={buildVisualNovelEntryGenerationProgress(
|
||||
visualNovelGenerationStartedAtMs,
|
||||
visualNovelGenerationPhase,
|
||||
miniGameGenerationProgressNowMs,
|
||||
)}
|
||||
isGenerating={isVisualNovelBusy || isVisualNovelStreamingReply}
|
||||
error={visualNovelError}
|
||||
onBack={leaveVisualNovelFlow}
|
||||
onEditSetting={() => {
|
||||
setSelectionStage('visual-novel-agent-workspace');
|
||||
}}
|
||||
onExecuteAction={(payload) => {
|
||||
void visualNovelFlow.executeAction(payload);
|
||||
}}
|
||||
onOpenResult={openVisualNovelResult}
|
||||
onRetry={retryVisualNovelDraftGeneration}
|
||||
onInterrupt={undefined}
|
||||
backLabel="返回创作中心"
|
||||
settingActionLabel={null}
|
||||
retryLabel="重新生成草稿"
|
||||
settingTitle="当前视觉小说信息"
|
||||
settingDescription={null}
|
||||
progressTitle="视觉小说草稿生成进度"
|
||||
activeBadgeLabel="草稿生成中"
|
||||
pausedBadgeLabel="草稿生成已暂停"
|
||||
idleBadgeLabel="等待返回工作区"
|
||||
/>
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{selectionStage === 'visual-novel-result' &&
|
||||
visualNovelSession?.draft && (
|
||||
(visualNovelSession?.draft || visualNovelWork?.draft) && (
|
||||
<motion.div
|
||||
key="visual-novel-result"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
@@ -8576,7 +8897,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
fallback={<LazyPanelFallback label="正在加载视觉小说结果..." />}
|
||||
>
|
||||
<VisualNovelResultView
|
||||
draft={visualNovelWork?.draft ?? visualNovelSession.draft}
|
||||
draft={visualNovelWork?.draft ?? visualNovelSession?.draft}
|
||||
isBusy={isVisualNovelBusy}
|
||||
error={visualNovelError}
|
||||
onBack={() => {
|
||||
@@ -9049,9 +9370,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
});
|
||||
}}
|
||||
onSelectMatch3D={() => {
|
||||
runProtectedAction(() => {
|
||||
void openMatch3DAgentWorkspace();
|
||||
});
|
||||
handleCreationHubCreateType('match3d');
|
||||
}}
|
||||
onSelectSquareHole={() => {
|
||||
runProtectedAction(() => {
|
||||
@@ -9059,9 +9378,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
});
|
||||
}}
|
||||
onSelectPuzzle={() => {
|
||||
runProtectedAction(() => {
|
||||
void openPuzzleAgentWorkspace();
|
||||
});
|
||||
handleCreationHubCreateType('puzzle');
|
||||
}}
|
||||
onSelectCreativeAgent={() => {
|
||||
runProtectedAction(() => {
|
||||
@@ -9069,9 +9386,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
});
|
||||
}}
|
||||
onSelectVisualNovel={() => {
|
||||
runProtectedAction(() => {
|
||||
openVisualNovelAgentWorkspace();
|
||||
});
|
||||
handleCreationHubCreateType('visual-novel');
|
||||
}}
|
||||
/>
|
||||
<PublishShareModal
|
||||
|
||||
@@ -24,6 +24,7 @@ export type SelectionStage =
|
||||
| 'big-fish-result'
|
||||
| 'big-fish-runtime'
|
||||
| 'match3d-agent-workspace'
|
||||
| 'match3d-generating'
|
||||
| 'match3d-result'
|
||||
| 'match3d-runtime'
|
||||
| 'square-hole-agent-workspace'
|
||||
@@ -32,6 +33,7 @@ export type SelectionStage =
|
||||
| 'square-hole-runtime'
|
||||
| 'creative-agent-workspace'
|
||||
| 'visual-novel-agent-workspace'
|
||||
| 'visual-novel-generating'
|
||||
| 'visual-novel-result'
|
||||
| 'visual-novel-gallery-detail'
|
||||
| 'visual-novel-runtime'
|
||||
|
||||
@@ -1,11 +1,37 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import { puzzleAssetClient } from '../../services/puzzle-works/puzzleAssetClient';
|
||||
import { PuzzleAgentWorkspace } from './PuzzleAgentWorkspace';
|
||||
|
||||
vi.mock('../ResolvedAssetImage', () => ({
|
||||
ResolvedAssetImage: ({
|
||||
src,
|
||||
alt,
|
||||
className,
|
||||
}: {
|
||||
src?: string | null;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/puzzle-works/puzzleAssetClient', () => ({
|
||||
puzzleAssetClient: {
|
||||
listHistoryAssets: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const baseSession: PuzzleAgentSessionSnapshot = {
|
||||
sessionId: 'puzzle-session-1',
|
||||
currentTurn: 3,
|
||||
@@ -70,6 +96,7 @@ afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function stubReferenceImageUpload(dataUrl: string, width = 512, height = 512) {
|
||||
@@ -221,6 +248,66 @@ test('puzzle workspace keeps the reference image upload as a primary panel', ()
|
||||
);
|
||||
});
|
||||
|
||||
test('puzzle workspace selects a history image from the upload card', async () => {
|
||||
const onCreateFromForm = vi.fn();
|
||||
vi.mocked(puzzleAssetClient.listHistoryAssets).mockResolvedValue([
|
||||
{
|
||||
assetObjectId: 'asset-history-1',
|
||||
assetKind: 'puzzle_cover_image',
|
||||
imageSrc: '/generated-puzzle-assets/history/image.png',
|
||||
ownerUserId: 'user-1',
|
||||
ownerLabel: '账号 user-1',
|
||||
profileId: null,
|
||||
entityId: 'puzzle-session-1',
|
||||
createdAt: '2026-04-27T10:00:00.000Z',
|
||||
updatedAt: '2026-04-27T10:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
|
||||
render(
|
||||
<PuzzleAgentWorkspace
|
||||
session={null}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
onCreateFromForm={onCreateFromForm}
|
||||
/>,
|
||||
);
|
||||
|
||||
const historyButton = screen.getByRole('button', { name: '选择历史图片' });
|
||||
expect(historyButton.closest('.puzzle-image-upload-card')).toBeTruthy();
|
||||
expect(screen.getByText('历史').closest('.puzzle-image-upload-card')).toBeTruthy();
|
||||
fireEvent.click(historyButton);
|
||||
|
||||
const picker = await screen.findByRole('dialog', {
|
||||
name: '选择历史图片',
|
||||
});
|
||||
fireEvent.click(
|
||||
await within(picker).findByRole('button', { name: /账号 user-1/u }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog', { name: '选择历史图片' })).toBeNull();
|
||||
});
|
||||
expect(screen.getByAltText('拼图图片')).toHaveProperty(
|
||||
'src',
|
||||
expect.stringContaining('/generated-puzzle-assets/history/image.png'),
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('画面AI重绘要求(提示词)'), {
|
||||
target: { value: '保留历史图里的主体,改成晴天花园。' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成拼图游戏草稿/u }));
|
||||
|
||||
expect(onCreateFromForm).toHaveBeenCalledWith({
|
||||
seedText: '保留历史图里的主体,改成晴天花园。',
|
||||
pictureDescription: '保留历史图里的主体,改成晴天花园。',
|
||||
referenceImageSrc: '/generated-puzzle-assets/history/image.png',
|
||||
imageModel: 'gpt-image-2',
|
||||
aiRedraw: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('puzzle upload card stays light in light theme', () => {
|
||||
const onCreateFromForm = vi.fn();
|
||||
const { container } = render(
|
||||
@@ -237,6 +324,9 @@ test('puzzle upload card stays light in light theme', () => {
|
||||
const uploadLabel = screen.getByText('点击上传拼图图片');
|
||||
expect(uploadLabel).toBeTruthy();
|
||||
expect(uploadLabel.closest('.puzzle-image-upload-card')).toBeTruthy();
|
||||
expect(uploadLabel.className).not.toContain('rounded-full');
|
||||
expect(uploadLabel.className).not.toContain('bg-white/94');
|
||||
expect(uploadLabel.className).not.toContain('border');
|
||||
expect(screen.queryByText('AI重绘')).toBeNull();
|
||||
expect(container.querySelector('.puzzle-image-upload-card')?.className).toContain(
|
||||
'bg-white/90',
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { ArrowLeft, ImagePlus, Loader2, Sparkles, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
ArrowLeft,
|
||||
History,
|
||||
ImagePlus,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
type ChangeEvent,
|
||||
type CSSProperties,
|
||||
@@ -20,6 +27,7 @@ import {
|
||||
isPuzzleReferenceImageSquare,
|
||||
readPuzzleReferenceImageForUpload,
|
||||
} from '../../services/puzzleReferenceImage';
|
||||
import PuzzleHistoryAssetPickerDialog from './PuzzleHistoryAssetPickerDialog';
|
||||
import {
|
||||
normalizePuzzleImageModel,
|
||||
PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
|
||||
@@ -535,6 +543,7 @@ export function PuzzleAgentWorkspace({
|
||||
null,
|
||||
);
|
||||
const [cropState, setCropState] = useState<PuzzleImageCropState | null>(null);
|
||||
const [isHistoryPickerOpen, setIsHistoryPickerOpen] = useState(false);
|
||||
const [isRemoveImageConfirmOpen, setIsRemoveImageConfirmOpen] =
|
||||
useState(false);
|
||||
const previousSessionIdRef = useRef<string | null>(
|
||||
@@ -565,6 +574,7 @@ export function PuzzleAgentWorkspace({
|
||||
setFormState(resolveInitialFormState(session, initialFormPayload));
|
||||
setReferenceImageError(null);
|
||||
setCropState(null);
|
||||
setIsHistoryPickerOpen(false);
|
||||
setIsRemoveImageConfirmOpen(false);
|
||||
}, [initialFormPayload, session]);
|
||||
|
||||
@@ -865,6 +875,17 @@ export function PuzzleAgentWorkspace({
|
||||
</span>
|
||||
)}
|
||||
<div className="absolute inset-0 z-[1] bg-[linear-gradient(180deg,rgba(255,255,255,0.12)_0%,rgba(255,255,255,0.04)_42%,rgba(255,255,255,0.18)_100%)] pointer-events-none" />
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => setIsHistoryPickerOpen(true)}
|
||||
className={`absolute bottom-3 right-3 z-10 inline-flex items-center gap-1.5 rounded-full border border-white/80 bg-white/94 px-3 py-2 text-[11px] font-black text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[#ff4056] ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
aria-label="选择历史图片"
|
||||
title="选择历史图片"
|
||||
>
|
||||
<History className="h-3.5 w-3.5" />
|
||||
<span>历史</span>
|
||||
</button>
|
||||
{formState.referenceImageSrc ? (
|
||||
<label className="absolute bottom-3 left-3 z-10 inline-flex cursor-pointer items-center gap-2 rounded-full border border-white/80 bg-white/94 px-3 py-2 text-xs font-black text-[var(--platform-text-strong)] shadow-sm backdrop-blur">
|
||||
<span>AI重绘</span>
|
||||
@@ -910,7 +931,7 @@ export function PuzzleAgentWorkspace({
|
||||
) : (
|
||||
<label
|
||||
htmlFor="puzzle-image-upload-input"
|
||||
className={`absolute bottom-3 left-1/2 z-10 inline-flex min-h-10 -translate-x-1/2 items-center justify-center whitespace-nowrap rounded-full border border-white/80 bg-white/94 px-4 text-sm font-black text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[#ff4056] ${isBusy ? 'cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
className={`absolute bottom-9 left-1/2 z-10 -translate-x-1/2 whitespace-nowrap text-center text-sm font-black text-[var(--platform-text-strong)] drop-shadow-[0_1px_0_rgba(255,255,255,0.82)] transition hover:text-[#ff4056] sm:bottom-10 ${isBusy ? 'cursor-not-allowed opacity-55' : 'cursor-pointer'}`}
|
||||
>
|
||||
点击上传拼图图片
|
||||
</label>
|
||||
@@ -1003,6 +1024,22 @@ export function PuzzleAgentWorkspace({
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{isHistoryPickerOpen ? (
|
||||
<PuzzleHistoryAssetPickerDialog
|
||||
isBusy={isBusy}
|
||||
onClose={() => setIsHistoryPickerOpen(false)}
|
||||
onSelect={(asset) => {
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
referenceImageSrc: asset.imageSrc,
|
||||
referenceImageLabel: `历史素材 · ${asset.ownerLabel || '未记录账号'}`,
|
||||
}));
|
||||
setReferenceImageError(null);
|
||||
setIsHistoryPickerOpen(false);
|
||||
setIsRemoveImageConfirmOpen(false);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{isRemoveImageConfirmOpen ? (
|
||||
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
|
||||
<div
|
||||
|
||||
164
src/components/puzzle-agent/PuzzleHistoryAssetPickerDialog.tsx
Normal file
164
src/components/puzzle-agent/PuzzleHistoryAssetPickerDialog.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { X } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import {
|
||||
puzzleAssetClient,
|
||||
type PuzzleHistoryAsset,
|
||||
} from '../../services/puzzle-works/puzzleAssetClient';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
|
||||
type PuzzleHistoryAssetPickerDialogProps = {
|
||||
isBusy: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (asset: PuzzleHistoryAsset) => void;
|
||||
};
|
||||
|
||||
function formatHistoryAssetDate(value: string) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '未知时间';
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function PuzzleHistoryAssetPickerDialog({
|
||||
isBusy,
|
||||
onClose,
|
||||
onSelect,
|
||||
}: PuzzleHistoryAssetPickerDialogProps) {
|
||||
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
|
||||
const [assets, setAssets] = useState<PuzzleHistoryAsset[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
puzzleAssetClient
|
||||
.listHistoryAssets({ limit: 120 })
|
||||
.then((nextAssets) => {
|
||||
if (!cancelled) {
|
||||
setAssets(nextAssets);
|
||||
}
|
||||
})
|
||||
.catch((loadError) => {
|
||||
if (!cancelled) {
|
||||
setError(
|
||||
loadError instanceof Error
|
||||
? loadError.message
|
||||
: '历史拼图素材读取失败。',
|
||||
);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[145] 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(92vh,46rem)] w-full max-w-5xl 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 className="text-base font-semibold text-[var(--platform-text-strong)]">
|
||||
选择历史图片
|
||||
</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">
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger text-sm leading-6">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex min-h-[14rem] items-center justify-center rounded-[1.35rem] border border-dashed border-[var(--platform-subpanel-border)] bg-white/52 px-6 text-center text-sm text-[var(--platform-text-base)]">
|
||||
读取中...
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isLoading && !error && assets.length <= 0 ? (
|
||||
<div className="flex min-h-[14rem] items-center justify-center rounded-[1.35rem] border border-dashed border-[var(--platform-subpanel-border)] bg-white/52 px-6 text-center text-sm text-[var(--platform-text-base)]">
|
||||
暂无历史拼图素材
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isLoading && assets.length > 0 ? (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{assets.map((asset) => (
|
||||
<button
|
||||
key={asset.assetObjectId}
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => onSelect(asset)}
|
||||
className={`overflow-hidden rounded-[1.25rem] border bg-white/82 text-left transition hover:border-amber-300/70 hover:bg-white ${isBusy ? 'cursor-not-allowed opacity-55' : 'border-[var(--platform-subpanel-border)]'}`}
|
||||
>
|
||||
<div className="aspect-square overflow-hidden bg-[var(--platform-subpanel-fill)]">
|
||||
<ResolvedAssetImage
|
||||
src={asset.imageSrc}
|
||||
alt={asset.ownerLabel || '历史拼图素材'}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 px-4 py-4">
|
||||
<div className="truncate text-sm font-black text-[var(--platform-text-strong)]">
|
||||
{asset.ownerLabel || '未记录账号'}
|
||||
</div>
|
||||
<div className="text-xs leading-5 text-[var(--platform-text-base)]">
|
||||
{formatHistoryAssetDate(asset.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export default PuzzleHistoryAssetPickerDialog;
|
||||
@@ -249,6 +249,7 @@ describe('PuzzleResultView', () => {
|
||||
promptText: '一只猫在雨夜灯牌下回头。',
|
||||
referenceImageSrc: undefined,
|
||||
imageModel: 'gpt-image-2',
|
||||
aiRedraw: true,
|
||||
candidateCount: 1,
|
||||
workTitle: '暖灯猫街作品',
|
||||
workDescription: '一套雨夜猫街主题拼图。',
|
||||
@@ -263,8 +264,15 @@ describe('PuzzleResultView', () => {
|
||||
levelId: 'puzzle-level-1',
|
||||
levelName: '暖灯猫街',
|
||||
pictureDescription: '一只猫在雨夜灯牌下回头。',
|
||||
generationStatus: 'generating',
|
||||
}),
|
||||
]);
|
||||
expect(within(dialog).getByText('预计剩余 90 秒')).toBeTruthy();
|
||||
expect(
|
||||
within(dialog).queryByPlaceholderText('参考图链接或资产ID'),
|
||||
).toBeNull();
|
||||
const levelList = screen.getByLabelText('拼图关卡列表');
|
||||
expect(within(levelList).getAllByText('生成中').length).toBeGreaterThan(0);
|
||||
|
||||
const levelNameInput = within(dialog).getByLabelText('关卡名称');
|
||||
const formalImageTitle = within(dialog).getByText('画面图');
|
||||
@@ -314,7 +322,10 @@ describe('PuzzleResultView', () => {
|
||||
within(dialog).getByRole('button', { name: /生成画面/u }),
|
||||
).toBeTruthy();
|
||||
expect(within(dialog).getByText('消耗2光点')).toBeTruthy();
|
||||
expect(within(dialog).queryByText('画面图')).toBeNull();
|
||||
expect(
|
||||
within(dialog).getByText('等待时间可以制作更多关卡哦~'),
|
||||
).toBeTruthy();
|
||||
expect(within(dialog).getByText('画面图')).toBeTruthy();
|
||||
expect(
|
||||
within(dialog).queryByRole('button', { name: /关卡测试/u }),
|
||||
).toBeNull();
|
||||
@@ -385,6 +396,7 @@ describe('PuzzleResultView', () => {
|
||||
promptText: '新关卡里有一座发光钟楼。',
|
||||
referenceImageSrc: undefined,
|
||||
imageModel: 'gpt-image-2',
|
||||
aiRedraw: true,
|
||||
candidateCount: 1,
|
||||
workTitle: '暖灯猫街作品',
|
||||
workDescription: '一套雨夜猫街主题拼图。',
|
||||
@@ -400,10 +412,90 @@ describe('PuzzleResultView', () => {
|
||||
levelId: 'puzzle-level-1775000000000-2',
|
||||
levelName: '',
|
||||
pictureDescription: '新关卡里有一座发光钟楼。',
|
||||
generationStatus: 'generating',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test('keeps generation progress visible after closing and reopening level dialog', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('雨夜猫街'));
|
||||
fireEvent.click(screen.getByRole('button', { name: /重新生成画面/u }));
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('dialog', { name: '确认消耗光点' })).getByRole(
|
||||
'button',
|
||||
{ name: '确定' },
|
||||
),
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText('关闭'));
|
||||
|
||||
expect(
|
||||
within(screen.getByLabelText('拼图关卡列表')).getAllByText('生成中')
|
||||
.length,
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
fireEvent.click(screen.getByText('雨夜猫街'));
|
||||
const reopenedDialog = screen.getByRole('dialog', { name: '关卡详情' });
|
||||
expect(
|
||||
within(reopenedDialog).getByRole('progressbar', { name: '画面生成进度' }),
|
||||
).toBeTruthy();
|
||||
expect(within(reopenedDialog).getByText('预计剩余 90 秒')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('allows parallel draft editing while a level image is generating but blocks publish', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
const onStartTestRun = vi.fn();
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
onStartTestRun={onStartTestRun}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('雨夜猫街'));
|
||||
fireEvent.click(screen.getByRole('button', { name: /重新生成画面/u }));
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('dialog', { name: '确认消耗光点' })).getByRole(
|
||||
'button',
|
||||
{ name: '确定' },
|
||||
),
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText('关卡名称'), {
|
||||
target: { value: '继续编辑的猫街' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /关卡测试/u }));
|
||||
|
||||
expect(onStartTestRun).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
levelName: '继续编辑的猫街',
|
||||
}),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('关闭'));
|
||||
fireEvent.click(screen.getByRole('button', { name: /新增关卡/u }));
|
||||
expect(screen.getByRole('dialog', { name: '关卡详情' })).toBeTruthy();
|
||||
fireEvent.click(screen.getByLabelText('关闭'));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /发布/u }));
|
||||
const publishDialog = screen.getByRole('dialog', { name: '发布拼图作品' });
|
||||
expect(within(publishDialog).getByText('还有关卡画面正在生成。')).toBeTruthy();
|
||||
expect(
|
||||
within(publishDialog).getByRole('button', { name: '发布到广场' }),
|
||||
).toHaveProperty('disabled', true);
|
||||
});
|
||||
|
||||
test('publishes with work info and serialized levels', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
@@ -552,19 +644,26 @@ describe('PuzzleResultView', () => {
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('雨夜猫街'));
|
||||
fireEvent.click(screen.getByLabelText('从历史拼图素材库选择'));
|
||||
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
|
||||
const uploadInput = within(dialog).getByLabelText('上传参考图', {
|
||||
selector: 'input',
|
||||
});
|
||||
expect(uploadInput.closest('.platform-subpanel')).toBeTruthy();
|
||||
const historyButton = within(dialog).getByRole('button', {
|
||||
name: '选择历史图片',
|
||||
});
|
||||
expect(within(historyButton).getByText('历史')).toBeTruthy();
|
||||
fireEvent.click(historyButton);
|
||||
|
||||
const picker = await screen.findByRole('dialog', {
|
||||
name: '选择历史拼图素材',
|
||||
name: '选择历史图片',
|
||||
});
|
||||
fireEvent.click(
|
||||
await within(picker).findByRole('button', { name: /账号 user-1/u }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: '选择历史拼图素材' }),
|
||||
).toBeNull();
|
||||
expect(screen.queryByRole('dialog', { name: '选择历史图片' })).toBeNull();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /重新生成画面/u }));
|
||||
@@ -580,6 +679,7 @@ describe('PuzzleResultView', () => {
|
||||
promptText: '屋檐下的猫与暖灯街角。',
|
||||
referenceImageSrc: '/generated-puzzle-assets/history/image.png',
|
||||
imageModel: 'gpt-image-2',
|
||||
aiRedraw: true,
|
||||
candidateCount: 1,
|
||||
workTitle: '暖灯猫街作品',
|
||||
workDescription: '一套雨夜猫街主题拼图。',
|
||||
@@ -624,8 +724,10 @@ describe('PuzzleResultView', () => {
|
||||
expect.objectContaining({
|
||||
action: 'generate_puzzle_images',
|
||||
referenceImageSrc: '/generated-puzzle-assets/history/saved-reference.png',
|
||||
aiRedraw: true,
|
||||
}),
|
||||
);
|
||||
expect(screen.queryByPlaceholderText('参考图链接或资产ID')).toBeNull();
|
||||
});
|
||||
|
||||
test('passes the selected image model when regenerating a level image', () => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
History,
|
||||
ImagePlus,
|
||||
Images,
|
||||
Loader2,
|
||||
MessageSquareText,
|
||||
Play,
|
||||
@@ -22,12 +22,9 @@ import type {
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
|
||||
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import { updatePuzzleWork } from '../../services/puzzle-works';
|
||||
import {
|
||||
puzzleAssetClient,
|
||||
type PuzzleHistoryAsset,
|
||||
} from '../../services/puzzle-works/puzzleAssetClient';
|
||||
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import PuzzleHistoryAssetPickerDialog from '../puzzle-agent/PuzzleHistoryAssetPickerDialog';
|
||||
import {
|
||||
PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
|
||||
type PuzzleImageModelId,
|
||||
@@ -67,7 +64,46 @@ const PUZZLE_MIN_THEME_TAG_COUNT = 3;
|
||||
const PUZZLE_MAX_THEME_TAG_COUNT = 6;
|
||||
const PUZZLE_AUTOSAVE_DEBOUNCE_MS = 600;
|
||||
const PUZZLE_IMAGE_GENERATION_POINT_COST = 2;
|
||||
const PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS = 30;
|
||||
const PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS = 90;
|
||||
|
||||
type PuzzleLevelGenerationRuntime = {
|
||||
startedAtMs: number;
|
||||
estimateSeconds: number;
|
||||
};
|
||||
|
||||
function resolvePuzzleLevelGenerationProgress(
|
||||
level: PuzzleDraftLevel,
|
||||
runtime: PuzzleLevelGenerationRuntime | null,
|
||||
nowMs: number,
|
||||
) {
|
||||
if (level.generationStatus !== 'generating') {
|
||||
return {
|
||||
isGenerating: false,
|
||||
progressPercent: 0,
|
||||
secondsLeft: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const estimateSeconds =
|
||||
runtime?.estimateSeconds ?? PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS;
|
||||
const elapsedSeconds = runtime
|
||||
? Math.max(0, Math.floor((nowMs - runtime.startedAtMs) / 1000))
|
||||
: 0;
|
||||
const secondsLeft = Math.max(0, estimateSeconds - elapsedSeconds);
|
||||
const progressPercent = Math.min(
|
||||
96,
|
||||
Math.max(
|
||||
6,
|
||||
Math.round(((estimateSeconds - secondsLeft) / estimateSeconds) * 100),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
isGenerating: true,
|
||||
progressPercent,
|
||||
secondsLeft,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeThemeTagInput(value: string) {
|
||||
return [
|
||||
@@ -163,6 +199,61 @@ function createDraftEditState(draft: PuzzleResultDraft): DraftEditState {
|
||||
};
|
||||
}
|
||||
|
||||
function mergeDraftEditStateWithIncomingState(
|
||||
currentState: DraftEditState | null,
|
||||
incomingState: DraftEditState,
|
||||
): DraftEditState {
|
||||
if (!currentState) {
|
||||
return incomingState;
|
||||
}
|
||||
|
||||
const incomingLevelsById = new Map(
|
||||
incomingState.levels.map((level) => [level.levelId, level]),
|
||||
);
|
||||
const shouldPreserveLocalEdits = currentState.levels.some((level) => {
|
||||
const incomingLevel = incomingLevelsById.get(level.levelId);
|
||||
return (
|
||||
level.generationStatus === 'generating' &&
|
||||
Boolean(incomingLevel) &&
|
||||
incomingLevel?.generationStatus !== 'generating'
|
||||
);
|
||||
});
|
||||
|
||||
if (!shouldPreserveLocalEdits) {
|
||||
return incomingState;
|
||||
}
|
||||
|
||||
const mergedLevels = currentState.levels.map((level) => {
|
||||
const incomingLevel = incomingLevelsById.get(level.levelId);
|
||||
if (
|
||||
!incomingLevel ||
|
||||
level.generationStatus !== 'generating' ||
|
||||
incomingLevel.generationStatus === 'generating'
|
||||
) {
|
||||
return level;
|
||||
}
|
||||
|
||||
return {
|
||||
...level,
|
||||
candidates: incomingLevel.candidates,
|
||||
selectedCandidateId: incomingLevel.selectedCandidateId,
|
||||
coverImageSrc: incomingLevel.coverImageSrc,
|
||||
coverAssetId: incomingLevel.coverAssetId,
|
||||
pictureReference: incomingLevel.pictureReference ?? level.pictureReference,
|
||||
generationStatus: incomingLevel.generationStatus || 'ready',
|
||||
};
|
||||
});
|
||||
const mergedLevelIds = new Set(mergedLevels.map((level) => level.levelId));
|
||||
const appendedIncomingLevels = incomingState.levels.filter(
|
||||
(level) => !mergedLevelIds.has(level.levelId),
|
||||
);
|
||||
|
||||
return {
|
||||
...currentState,
|
||||
levels: [...mergedLevels, ...appendedIncomingLevels],
|
||||
};
|
||||
}
|
||||
|
||||
function createBlankPuzzleLevel(
|
||||
existingLevels: PuzzleDraftLevel[],
|
||||
): PuzzleDraftLevel {
|
||||
@@ -180,19 +271,6 @@ function createBlankPuzzleLevel(
|
||||
};
|
||||
}
|
||||
|
||||
function formatHistoryAssetDate(value: string) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value || '';
|
||||
}
|
||||
return date.toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function buildPublishReady(
|
||||
session: PuzzleAgentSessionSnapshot,
|
||||
editState: DraftEditState,
|
||||
@@ -209,6 +287,9 @@ function buildPublishReady(
|
||||
)
|
||||
.map((entry) => entry.message) ?? [];
|
||||
const levels = editState.levels;
|
||||
const hasGeneratingLevel = levels.some(
|
||||
(level) => level.generationStatus === 'generating',
|
||||
);
|
||||
const blockers = [
|
||||
...(session.resultPreview ? [] : ['等待结果页草稿完成后再发布。']),
|
||||
...preservedBlockers,
|
||||
@@ -226,7 +307,11 @@ function buildPublishReady(
|
||||
...(resolveLevelFormalImageSrc(level)
|
||||
? []
|
||||
: [`第${index + 1}关缺少正式图。`]),
|
||||
...(level.generationStatus === 'generating'
|
||||
? [`第${index + 1}关画面正在生成。`]
|
||||
: []),
|
||||
]),
|
||||
...(hasGeneratingLevel ? ['还有关卡画面正在生成。'] : []),
|
||||
];
|
||||
|
||||
return {
|
||||
@@ -462,142 +547,10 @@ function PuzzleThemeTagEditor({
|
||||
);
|
||||
}
|
||||
|
||||
function PuzzleHistoryAssetPickerDialog({
|
||||
isBusy,
|
||||
onClose,
|
||||
onSelect,
|
||||
}: {
|
||||
isBusy: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (asset: PuzzleHistoryAsset) => void;
|
||||
}) {
|
||||
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
|
||||
const [assets, setAssets] = useState<PuzzleHistoryAsset[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
puzzleAssetClient
|
||||
.listHistoryAssets({ limit: 120 })
|
||||
.then((nextAssets) => {
|
||||
if (!cancelled) {
|
||||
setAssets(nextAssets);
|
||||
}
|
||||
})
|
||||
.catch((loadError) => {
|
||||
if (!cancelled) {
|
||||
setError(
|
||||
loadError instanceof Error
|
||||
? loadError.message
|
||||
: '历史拼图素材读取失败。',
|
||||
);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[145] 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(92vh,46rem)] w-full max-w-5xl 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 className="text-base font-semibold text-[var(--platform-text-strong)]">
|
||||
选择历史拼图素材
|
||||
</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">
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger text-sm leading-6">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex min-h-[14rem] items-center justify-center rounded-[1.35rem] border border-dashed border-[var(--platform-subpanel-border)] bg-white/52 px-6 text-center text-sm text-[var(--platform-text-base)]">
|
||||
读取中...
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isLoading && !error && assets.length <= 0 ? (
|
||||
<div className="flex min-h-[14rem] items-center justify-center rounded-[1.35rem] border border-dashed border-[var(--platform-subpanel-border)] bg-white/52 px-6 text-center text-sm text-[var(--platform-text-base)]">
|
||||
暂无历史拼图素材
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isLoading && assets.length > 0 ? (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{assets.map((asset) => (
|
||||
<button
|
||||
key={asset.assetObjectId}
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => onSelect(asset)}
|
||||
className={`overflow-hidden rounded-[1.25rem] border bg-white/82 text-left transition hover:border-amber-300/70 hover:bg-white ${isBusy ? 'cursor-not-allowed opacity-55' : 'border-[var(--platform-subpanel-border)]'}`}
|
||||
>
|
||||
<div className="aspect-square overflow-hidden bg-[var(--platform-subpanel-fill)]">
|
||||
<ResolvedAssetImage
|
||||
src={asset.imageSrc}
|
||||
alt={asset.ownerLabel || '历史拼图素材'}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 px-4 py-4">
|
||||
<div className="truncate text-sm font-black text-[var(--platform-text-strong)]">
|
||||
{asset.ownerLabel || '未记录账号'}
|
||||
</div>
|
||||
<div className="text-xs leading-5 text-[var(--platform-text-base)]">
|
||||
{formatHistoryAssetDate(asset.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
function PuzzleLevelDetailDialog({
|
||||
draft,
|
||||
generationNowMs,
|
||||
generationRuntime,
|
||||
imageRefreshKey,
|
||||
isBusy,
|
||||
level,
|
||||
@@ -607,12 +560,14 @@ function PuzzleLevelDetailDialog({
|
||||
onStartTestRun,
|
||||
}: {
|
||||
draft: PuzzleResultDraft;
|
||||
generationNowMs: number;
|
||||
generationRuntime: PuzzleLevelGenerationRuntime | null;
|
||||
imageRefreshKey: string;
|
||||
isBusy: boolean;
|
||||
level: PuzzleDraftLevel;
|
||||
onClose: () => void;
|
||||
onGenerate: (
|
||||
levelId: string,
|
||||
level: PuzzleDraftLevel,
|
||||
promptText?: string | null,
|
||||
referenceImageSrc?: string | null,
|
||||
imageModel?: PuzzleImageModelId | null,
|
||||
@@ -628,10 +583,6 @@ function PuzzleLevelDetailDialog({
|
||||
);
|
||||
const [isHistoryPickerOpen, setIsHistoryPickerOpen] = useState(false);
|
||||
const [isCostConfirmOpen, setIsCostConfirmOpen] = useState(false);
|
||||
const [isGenerationProgressActive, setIsGenerationProgressActive] =
|
||||
useState(false);
|
||||
const [generationCountdown, setGenerationCountdown] = useState(0);
|
||||
const generationBusySeenRef = useRef(false);
|
||||
const [imageModel, setImageModel] = useState<PuzzleImageModelId>(
|
||||
PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
|
||||
);
|
||||
@@ -639,18 +590,14 @@ function PuzzleLevelDetailDialog({
|
||||
const hasFormalImage = Boolean(formalImageSrc);
|
||||
const effectiveReferenceImageSrc =
|
||||
referenceImageSrc.trim() || level.pictureReference?.trim() || '';
|
||||
const isGenerationProgressVisible = isGenerationProgressActive;
|
||||
const generationSecondsLeft = isBusy
|
||||
? Math.max(generationCountdown, 1)
|
||||
: generationCountdown;
|
||||
const generationProgressPercent = Math.max(
|
||||
6,
|
||||
Math.round(
|
||||
((PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS -
|
||||
Math.max(generationSecondsLeft, 0)) /
|
||||
PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS) *
|
||||
100,
|
||||
),
|
||||
const displayImageSrc = formalImageSrc || effectiveReferenceImageSrc;
|
||||
const displayImageAlt = formalImageSrc
|
||||
? level.levelName || draft.workTitle || '拼图关卡'
|
||||
: '拼图参考图';
|
||||
const generationProgress = resolvePuzzleLevelGenerationProgress(
|
||||
level,
|
||||
generationRuntime,
|
||||
generationNowMs,
|
||||
);
|
||||
|
||||
const handleReferenceImageChange = async (
|
||||
@@ -676,54 +623,15 @@ function PuzzleLevelDetailDialog({
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isGenerationProgressActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (generationCountdown <= 0) {
|
||||
if (!isBusy) {
|
||||
setIsGenerationProgressActive(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
setGenerationCountdown((current) => Math.max(0, current - 1));
|
||||
}, 1000);
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [generationCountdown, isBusy, isGenerationProgressActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isGenerationProgressActive && isBusy) {
|
||||
generationBusySeenRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
isGenerationProgressActive &&
|
||||
!isBusy &&
|
||||
generationBusySeenRef.current
|
||||
) {
|
||||
generationBusySeenRef.current = false;
|
||||
setIsGenerationProgressActive(false);
|
||||
setGenerationCountdown(0);
|
||||
}
|
||||
|
||||
if (!isBusy) {
|
||||
setIsCostConfirmOpen(false);
|
||||
}
|
||||
}, [isBusy, isGenerationProgressActive]);
|
||||
|
||||
const executeGeneration = () => {
|
||||
const nextLevel = {
|
||||
...level,
|
||||
generationStatus: 'generating' as const,
|
||||
};
|
||||
setIsCostConfirmOpen(false);
|
||||
setIsGenerationProgressActive(true);
|
||||
generationBusySeenRef.current = false;
|
||||
setGenerationCountdown(PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS);
|
||||
onGenerate(
|
||||
level.levelId,
|
||||
level.pictureDescription.trim() || undefined,
|
||||
nextLevel,
|
||||
nextLevel.pictureDescription.trim() || undefined,
|
||||
effectiveReferenceImageSrc || undefined,
|
||||
imageModel,
|
||||
);
|
||||
@@ -780,127 +688,134 @@ function PuzzleLevelDetailDialog({
|
||||
/>
|
||||
</section>
|
||||
|
||||
{hasFormalImage ? (
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(13rem,0.9fr)_minmax(0,1.1fr)]">
|
||||
<section className="platform-subpanel rounded-[1.35rem] p-4">
|
||||
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
<div className="mb-3 text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
画面图
|
||||
</div>
|
||||
<div className="relative mt-3 aspect-square overflow-hidden rounded-[1.15rem] bg-[var(--platform-subpanel-fill)]">
|
||||
<ResolvedAssetImage
|
||||
src={formalImageSrc}
|
||||
refreshKey={`${imageRefreshKey}:${level.levelId}`}
|
||||
alt={level.levelName || draft.workTitle || '拼图关卡'}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => setIsHistoryPickerOpen(true)}
|
||||
className={`absolute bottom-3 right-3 inline-flex h-10 w-10 items-center justify-center rounded-full border border-[var(--platform-subpanel-border)] bg-white/96 text-[var(--platform-text-strong)] shadow-sm transition hover:bg-[var(--platform-subpanel-fill)] ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
aria-label="从历史拼图素材库选择"
|
||||
title="从历史拼图素材库选择"
|
||||
>
|
||||
<Images className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="platform-subpanel rounded-[1.35rem] p-4">
|
||||
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
画面描述
|
||||
</div>
|
||||
<div className="relative mt-3">
|
||||
<textarea
|
||||
value={level.pictureDescription}
|
||||
disabled={isBusy}
|
||||
rows={9}
|
||||
onChange={(event) =>
|
||||
onLevelChange({
|
||||
...level,
|
||||
pictureDescription: event.target.value,
|
||||
})
|
||||
}
|
||||
className="w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 pb-16 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
|
||||
aria-label="画面描述"
|
||||
/>
|
||||
<PuzzleImageModelPicker
|
||||
value={imageModel}
|
||||
disabled={isBusy}
|
||||
onChange={setImageModel}
|
||||
/>
|
||||
<label
|
||||
className={`absolute bottom-3 right-3 inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-full border border-amber-300/70 bg-white/96 text-amber-700 shadow-sm transition hover:bg-amber-50 ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
title={effectiveReferenceImageSrc ? '更换参考图' : '添加参考图'}
|
||||
>
|
||||
<ImagePlus className="h-4 w-4" />
|
||||
<span className="sr-only">
|
||||
{effectiveReferenceImageSrc ? '更换参考图' : '添加参考图'}
|
||||
</span>
|
||||
<div className="relative aspect-square overflow-hidden rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/90 shadow-[0_12px_28px_rgba(15,23,42,0.08)]">
|
||||
<input
|
||||
id={`puzzle-level-reference-upload-${level.levelId}`}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
disabled={isBusy}
|
||||
aria-label="上传参考图"
|
||||
onChange={(event) => {
|
||||
void handleReferenceImageChange(event);
|
||||
}}
|
||||
className="hidden"
|
||||
className="sr-only"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<input
|
||||
value={level.pictureReference ?? ''}
|
||||
disabled={isBusy}
|
||||
onChange={(event) =>
|
||||
onLevelChange({
|
||||
...level,
|
||||
pictureReference: event.target.value,
|
||||
})
|
||||
}
|
||||
className="mt-3 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-sm text-[var(--platform-text-strong)] outline-none"
|
||||
placeholder="参考图链接或资产ID"
|
||||
aria-label="图面参考"
|
||||
/>
|
||||
|
||||
{effectiveReferenceImageSrc ? (
|
||||
<div className="mt-3 flex items-center gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-3">
|
||||
<div className="h-14 w-14 overflow-hidden rounded-[0.9rem] bg-[var(--platform-subpanel-fill)]">
|
||||
<ResolvedAssetImage
|
||||
src={effectiveReferenceImageSrc}
|
||||
alt="拼图参考图"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
{referenceImageLabel || '已选择参考图'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
setReferenceImageSrc('');
|
||||
setReferenceImageLabel('');
|
||||
setReferenceImageError(null);
|
||||
onLevelChange({ ...level, pictureReference: null });
|
||||
}}
|
||||
className="platform-icon-button h-9 w-9"
|
||||
aria-label="移除参考图"
|
||||
title="移除参考图"
|
||||
<label
|
||||
htmlFor={`puzzle-level-reference-upload-${level.levelId}`}
|
||||
className={`absolute inset-0 z-0 cursor-pointer ${isBusy ? 'cursor-not-allowed' : ''}`}
|
||||
title={
|
||||
effectiveReferenceImageSrc ? '更换参考图' : '上传参考图'
|
||||
}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="sr-only">
|
||||
{effectiveReferenceImageSrc ? '更换参考图' : '上传参考图'}
|
||||
</span>
|
||||
</label>
|
||||
{displayImageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={displayImageSrc}
|
||||
refreshKey={`${imageRefreshKey}:${level.levelId}`}
|
||||
alt={displayImageAlt}
|
||||
className="pointer-events-none h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="pointer-events-none flex h-full items-center justify-center bg-[radial-gradient(circle_at_50%_28%,rgba(255,255,255,0.92),transparent_38%),linear-gradient(135deg,rgba(255,255,255,0.96),rgba(255,241,229,0.86))]">
|
||||
<span className="flex h-16 w-16 items-center justify-center rounded-full border border-[var(--platform-subpanel-border)] bg-white/92 text-[var(--platform-text-strong)] shadow-sm">
|
||||
<ImagePlus className="h-7 w-7" />
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{generationProgress.isGenerating ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/68 backdrop-blur-[2px]">
|
||||
<div className="flex items-center gap-2 rounded-full border border-white/80 bg-white/94 px-4 py-2 text-sm font-black text-[var(--platform-text-strong)] shadow-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-amber-600" />
|
||||
生成中
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="absolute bottom-3 right-3 z-10">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => setIsHistoryPickerOpen(true)}
|
||||
className={`inline-flex items-center gap-1.5 rounded-full border border-white/80 bg-white/94 px-3 py-2 text-[11px] font-black text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[#ff4056] ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
aria-label="选择历史图片"
|
||||
title="选择历史图片"
|
||||
>
|
||||
<History className="h-3.5 w-3.5" />
|
||||
<span>历史</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{effectiveReferenceImageSrc ? (
|
||||
<div className="mt-3 flex items-center gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-3">
|
||||
<div className="h-12 w-12 overflow-hidden rounded-[0.85rem] bg-[var(--platform-subpanel-fill)]">
|
||||
<ResolvedAssetImage
|
||||
src={effectiveReferenceImageSrc}
|
||||
alt="拼图参考图"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
{referenceImageLabel || '已选择参考图'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
setReferenceImageSrc('');
|
||||
setReferenceImageLabel('');
|
||||
setReferenceImageError(null);
|
||||
onLevelChange({ ...level, pictureReference: null });
|
||||
}}
|
||||
className="platform-icon-button h-9 w-9"
|
||||
aria-label="移除参考图"
|
||||
title="移除参考图"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{referenceImageError ? (
|
||||
<div className="mt-2 text-xs leading-5 text-red-600">
|
||||
{referenceImageError}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{referenceImageError ? (
|
||||
<div className="mt-2 text-xs leading-5 text-red-600">
|
||||
{referenceImageError}
|
||||
<section className="platform-subpanel rounded-[1.35rem] p-4">
|
||||
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
画面描述
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
<div className="relative mt-3">
|
||||
<textarea
|
||||
value={level.pictureDescription}
|
||||
disabled={isBusy}
|
||||
rows={7}
|
||||
onChange={(event) =>
|
||||
onLevelChange({
|
||||
...level,
|
||||
pictureDescription: event.target.value,
|
||||
})
|
||||
}
|
||||
className="h-[12rem] min-h-[12rem] w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 pb-16 text-sm leading-6 text-[var(--platform-text-strong)] outline-none sm:h-[14rem] sm:min-h-[14rem] lg:h-full lg:min-h-[18rem]"
|
||||
aria-label="画面描述"
|
||||
/>
|
||||
<PuzzleImageModelPicker
|
||||
value={imageModel}
|
||||
disabled={isBusy}
|
||||
onChange={setImageModel}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -919,22 +834,22 @@ function PuzzleLevelDetailDialog({
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{isGenerationProgressVisible ? (
|
||||
{generationProgress.isGenerating ? (
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label="画面生成进度"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={generationProgressPercent}
|
||||
aria-valuenow={generationProgress.progressPercent}
|
||||
className="platform-progress-track relative h-12 overflow-hidden rounded-full"
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full bg-amber-600 transition-[width] duration-300"
|
||||
style={{ width: `${generationProgressPercent}%` }}
|
||||
style={{ width: `${generationProgress.progressPercent}%` }}
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center gap-2 px-4 text-sm font-bold text-white">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
预计剩余 {generationSecondsLeft} 秒
|
||||
预计剩余 {generationProgress.secondsLeft} 秒
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -942,12 +857,17 @@ function PuzzleLevelDetailDialog({
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => setIsCostConfirmOpen(true)}
|
||||
className="inline-flex w-full items-center justify-center gap-2 rounded-full bg-amber-600 px-4 py-3 text-sm font-bold text-white disabled:opacity-45"
|
||||
className="inline-flex w-full flex-col items-center justify-center gap-1 rounded-full bg-amber-600 px-4 py-3 text-sm font-bold text-white disabled:opacity-45"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
<span>{hasFormalImage ? '重新生成画面' : '生成画面'}</span>
|
||||
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold">
|
||||
消耗{PUZZLE_IMAGE_GENERATION_POINT_COST}光点
|
||||
<span className="inline-flex items-center justify-center gap-2">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
<span>{hasFormalImage ? '重新生成画面' : '生成画面'}</span>
|
||||
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold">
|
||||
消耗{PUZZLE_IMAGE_GENERATION_POINT_COST}光点
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-[11px] font-semibold leading-none text-white/78">
|
||||
等待时间可以制作更多关卡哦~
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
@@ -986,8 +906,9 @@ function PuzzleLevelDetailDialog({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy || generationProgress.isGenerating}
|
||||
onClick={executeGeneration}
|
||||
className="platform-button platform-button--primary min-h-10 px-5 py-2 text-sm"
|
||||
className={`platform-button platform-button--primary min-h-10 px-5 py-2 text-sm ${isBusy || generationProgress.isGenerating ? 'opacity-55' : ''}`}
|
||||
>
|
||||
确定
|
||||
</button>
|
||||
@@ -1221,6 +1142,8 @@ function PuzzleCreativeDraftEditBar({
|
||||
|
||||
function PuzzleLevelListTab({
|
||||
editState,
|
||||
generationNowMs,
|
||||
generationRuntimeByLevelId,
|
||||
imageRefreshKey,
|
||||
isBusy,
|
||||
onAddLevel,
|
||||
@@ -1228,6 +1151,8 @@ function PuzzleLevelListTab({
|
||||
onOpenLevel,
|
||||
}: {
|
||||
editState: DraftEditState;
|
||||
generationNowMs: number;
|
||||
generationRuntimeByLevelId: Record<string, PuzzleLevelGenerationRuntime>;
|
||||
imageRefreshKey: string;
|
||||
isBusy: boolean;
|
||||
onAddLevel: () => void;
|
||||
@@ -1236,10 +1161,18 @@ function PuzzleLevelListTab({
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<div
|
||||
aria-label="拼图关卡列表"
|
||||
className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3"
|
||||
>
|
||||
{editState.levels.map((level, index) => {
|
||||
const imageSrc = resolveLevelFormalImageSrc(level);
|
||||
const displayLevelName = level.levelName || `第${index + 1}关`;
|
||||
const generationProgress = resolvePuzzleLevelGenerationProgress(
|
||||
level,
|
||||
generationRuntimeByLevelId[level.levelId] ?? null,
|
||||
generationNowMs,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={level.levelId}
|
||||
@@ -1250,7 +1183,7 @@ function PuzzleLevelListTab({
|
||||
onClick={() => onOpenLevel(level.levelId)}
|
||||
className="block w-full text-left"
|
||||
>
|
||||
<div className="aspect-[4/3] overflow-hidden bg-[var(--platform-subpanel-fill)]">
|
||||
<div className="relative aspect-[4/3] overflow-hidden bg-[var(--platform-subpanel-fill)]">
|
||||
{imageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={imageSrc}
|
||||
@@ -1263,10 +1196,23 @@ function PuzzleLevelListTab({
|
||||
暂无正式图
|
||||
</div>
|
||||
)}
|
||||
{generationProgress.isGenerating ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/68 backdrop-blur-[2px]">
|
||||
<div className="flex items-center gap-2 rounded-full border border-white/80 bg-white/94 px-3 py-1.5 text-xs font-black text-[var(--platform-text-strong)] shadow-sm">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-amber-600" />
|
||||
生成中
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-1 px-4 py-4">
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-[var(--platform-text-soft)]">
|
||||
第{index + 1}关
|
||||
<div className="flex items-center justify-between gap-2 text-[11px] font-bold tracking-[0.16em] text-[var(--platform-text-soft)]">
|
||||
<span>第{index + 1}关</span>
|
||||
{generationProgress.isGenerating ? (
|
||||
<span className="rounded-full bg-amber-100 px-2 py-0.5 tracking-normal text-amber-700">
|
||||
生成中
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@@ -1452,6 +1398,10 @@ export function PuzzleResultView({
|
||||
const [tagGenerationError, setTagGenerationError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [generationRuntimeByLevelId, setGenerationRuntimeByLevelId] = useState<
|
||||
Record<string, PuzzleLevelGenerationRuntime>
|
||||
>({});
|
||||
const [generationNowMs, setGenerationNowMs] = useState(() => Date.now());
|
||||
const savedEditStateRef = useRef<DraftEditState | null>(
|
||||
draft ? createDraftEditState(draft) : null,
|
||||
);
|
||||
@@ -1466,8 +1416,27 @@ export function PuzzleResultView({
|
||||
return;
|
||||
}
|
||||
const nextState = createDraftEditState(draft);
|
||||
savedEditStateRef.current = nextState;
|
||||
setEditState(nextState);
|
||||
setEditState((currentState) => {
|
||||
const mergedState = mergeDraftEditStateWithIncomingState(
|
||||
currentState,
|
||||
nextState,
|
||||
);
|
||||
savedEditStateRef.current = nextState;
|
||||
return mergedState;
|
||||
});
|
||||
setGenerationRuntimeByLevelId((current) => {
|
||||
const nextRuntimes: Record<string, PuzzleLevelGenerationRuntime> = {};
|
||||
nextState.levels.forEach((level) => {
|
||||
if (level.generationStatus === 'generating') {
|
||||
nextRuntimes[level.levelId] =
|
||||
current[level.levelId] ?? {
|
||||
startedAtMs: Date.now(),
|
||||
estimateSeconds: PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS,
|
||||
};
|
||||
}
|
||||
});
|
||||
return nextRuntimes;
|
||||
});
|
||||
setActiveLevelId((currentLevelId) =>
|
||||
currentLevelId &&
|
||||
nextState.levels.some((level) => level.levelId === currentLevelId)
|
||||
@@ -1492,6 +1461,45 @@ export function PuzzleResultView({
|
||||
const imageRefreshKey = `${session.updatedAt}:${primaryImageSrc}:${editState?.levels.length ?? 0}`;
|
||||
const activeLevel =
|
||||
editState?.levels.find((level) => level.levelId === activeLevelId) ?? null;
|
||||
const hasGeneratingLevel = Boolean(
|
||||
editState?.levels.some((level) => level.generationStatus === 'generating'),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasGeneratingLevel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
setGenerationNowMs(Date.now());
|
||||
}, 1000);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
}, [hasGeneratingLevel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeGeneratingLevelIds = new Set(
|
||||
editState.levels
|
||||
.filter((level) => level.generationStatus === 'generating')
|
||||
.map((level) => level.levelId),
|
||||
);
|
||||
setGenerationRuntimeByLevelId((current) => {
|
||||
let changed = false;
|
||||
const nextRuntime: Record<string, PuzzleLevelGenerationRuntime> = {};
|
||||
Object.entries(current).forEach(([levelId, runtime]) => {
|
||||
if (!activeGeneratingLevelIds.has(levelId)) {
|
||||
changed = true;
|
||||
return;
|
||||
}
|
||||
nextRuntime[levelId] = runtime;
|
||||
});
|
||||
return changed ? nextRuntime : current;
|
||||
});
|
||||
}, [editState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draft || !editState || !profileId) {
|
||||
@@ -1507,6 +1515,8 @@ export function PuzzleResultView({
|
||||
...level,
|
||||
levelName: level.levelName.trim(),
|
||||
pictureDescription: level.pictureDescription.trim(),
|
||||
pictureReference: level.pictureReference?.trim() || null,
|
||||
generationStatus: level.generationStatus || 'idle',
|
||||
})),
|
||||
};
|
||||
const originalState =
|
||||
@@ -1580,6 +1590,26 @@ export function PuzzleResultView({
|
||||
}
|
||||
|
||||
const updateLevel = (nextLevel: PuzzleDraftLevel) => {
|
||||
setGenerationRuntimeByLevelId((current) => {
|
||||
if (nextLevel.generationStatus === 'generating') {
|
||||
return {
|
||||
...current,
|
||||
[nextLevel.levelId]:
|
||||
current[nextLevel.levelId] ?? {
|
||||
startedAtMs: Date.now(),
|
||||
estimateSeconds: PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!current[nextLevel.levelId]) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const nextRuntime = { ...current };
|
||||
delete nextRuntime[nextLevel.levelId];
|
||||
return nextRuntime;
|
||||
});
|
||||
setEditState((currentState) =>
|
||||
currentState
|
||||
? {
|
||||
@@ -1627,6 +1657,8 @@ export function PuzzleResultView({
|
||||
{activeTab === 'levels' ? (
|
||||
<PuzzleLevelListTab
|
||||
editState={editState}
|
||||
generationNowMs={generationNowMs}
|
||||
generationRuntimeByLevelId={generationRuntimeByLevelId}
|
||||
imageRefreshKey={imageRefreshKey}
|
||||
isBusy={isBusy}
|
||||
onAddLevel={() => {
|
||||
@@ -1721,23 +1753,33 @@ export function PuzzleResultView({
|
||||
{activeLevel ? (
|
||||
<PuzzleLevelDetailDialog
|
||||
draft={syncedDraft}
|
||||
generationNowMs={generationNowMs}
|
||||
generationRuntime={
|
||||
generationRuntimeByLevelId[activeLevel.levelId] ?? null
|
||||
}
|
||||
imageRefreshKey={imageRefreshKey}
|
||||
isBusy={isBusy}
|
||||
level={activeLevel}
|
||||
onClose={() => setActiveLevelId(null)}
|
||||
onGenerate={(levelId, promptText, referenceImageSrc, imageModel) => {
|
||||
onGenerate={(nextLevel, promptText, referenceImageSrc, imageModel) => {
|
||||
updateLevel(nextLevel);
|
||||
onExecuteAction({
|
||||
action: 'generate_puzzle_images',
|
||||
levelId,
|
||||
levelId: nextLevel.levelId,
|
||||
promptText,
|
||||
referenceImageSrc,
|
||||
imageModel: imageModel ?? PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
|
||||
aiRedraw: true,
|
||||
candidateCount: 1,
|
||||
workTitle: editState.workTitle.trim(),
|
||||
workDescription: editState.workDescription.trim(),
|
||||
summary: editState.workDescription.trim(),
|
||||
themeTags: editState.themeTags,
|
||||
levelsJson: JSON.stringify(editState.levels),
|
||||
levelsJson: JSON.stringify(
|
||||
editState.levels.map((level) =>
|
||||
level.levelId === nextLevel.levelId ? nextLevel : level,
|
||||
),
|
||||
),
|
||||
});
|
||||
}}
|
||||
onLevelChange={updateLevel}
|
||||
|
||||
@@ -207,7 +207,8 @@ test('拼图界面在 mocap open_palm 时显示体感光标', () => {
|
||||
|
||||
const cursor = screen.getByTestId('puzzle-mocap-cursor');
|
||||
expect(cursor).toBeTruthy();
|
||||
expect(cursor).toHaveStyle({left: '42%', top: '58%'});
|
||||
expect(cursor.style.left).toBe('42%');
|
||||
expect(cursor.style.top).toBe('58%');
|
||||
mocapMock.state = 'grab';
|
||||
});
|
||||
|
||||
@@ -813,6 +814,7 @@ test('基础单块使用圆角裁剪图片', () => {
|
||||
) as HTMLElement | null;
|
||||
expect(basePiece?.className).toContain('overflow-hidden');
|
||||
expect(basePiece?.className).toContain('rounded-[0.85rem]');
|
||||
expect(basePiece?.querySelector('.puzzle-runtime-piece-overlay')).toBeNull();
|
||||
});
|
||||
|
||||
test('移动端点击拼图片时立即触发一次震动反馈', () => {
|
||||
|
||||
@@ -1347,7 +1347,6 @@ export function PuzzleRuntimeShell({
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-[linear-gradient(145deg,rgba(251,191,36,0.4),rgba(76,29,19,0.72))]" />
|
||||
)}
|
||||
<div className="puzzle-runtime-piece-overlay absolute inset-0" />
|
||||
</div>
|
||||
) : (
|
||||
''
|
||||
@@ -2000,7 +1999,7 @@ export function PuzzleRuntimeShell({
|
||||
排行榜
|
||||
</div>
|
||||
<div className="puzzle-runtime-dialog__line overflow-hidden rounded-[1rem] border">
|
||||
<div className="puzzle-runtime-leaderboard-head grid grid-cols-[3.5rem_minmax(0,1fr)_6rem] px-3 py-2 text-[11px] font-bold">
|
||||
<div className="puzzle-runtime-leaderboard-head grid grid-cols-[3rem_minmax(0,1fr)_5.75rem] px-3 py-2 text-[11px] font-bold">
|
||||
<span>名次</span>
|
||||
<span>昵称</span>
|
||||
<span className="text-right">通关时间</span>
|
||||
@@ -2010,7 +2009,7 @@ export function PuzzleRuntimeShell({
|
||||
leaderboardEntries.map((entry) => (
|
||||
<div
|
||||
key={`${entry.rank}:${entry.nickname}:${entry.elapsedMs}`}
|
||||
className={`grid grid-cols-[3.5rem_minmax(0,1fr)_6rem] items-center px-3 py-2.5 text-sm ${
|
||||
className={`grid min-h-[3.25rem] grid-cols-[3rem_minmax(0,1fr)_5.75rem] items-center gap-x-2 px-3 py-2.5 text-sm ${
|
||||
entry.isCurrentPlayer
|
||||
? 'puzzle-runtime-leaderboard-row--active'
|
||||
: 'puzzle-runtime-leaderboard-row border-t'
|
||||
@@ -2019,8 +2018,22 @@ export function PuzzleRuntimeShell({
|
||||
<span className="font-mono font-black">
|
||||
#{entry.rank}
|
||||
</span>
|
||||
<span className="truncate font-semibold">
|
||||
{entry.nickname}
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-semibold leading-tight">
|
||||
{entry.nickname}
|
||||
</span>
|
||||
{entry.visibleTags?.length ? (
|
||||
<span className="puzzle-runtime-leaderboard-tags">
|
||||
{entry.visibleTags.map((tag) => (
|
||||
<span
|
||||
className="puzzle-runtime-leaderboard-tag"
|
||||
key={tag}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="text-right font-mono text-xs font-bold">
|
||||
{formatElapsedMs(entry.elapsedMs)}
|
||||
@@ -2112,7 +2125,7 @@ function PuzzleNextWorkCard({
|
||||
) : (
|
||||
<div className="h-full w-full bg-[linear-gradient(145deg,rgba(20,184,166,0.34),rgba(15,23,42,0.88))]" />
|
||||
)}
|
||||
<div className="puzzle-runtime-piece-overlay absolute inset-0 transition group-hover:opacity-0" />
|
||||
<div className="puzzle-runtime-next-card-overlay absolute inset-0 transition group-hover:opacity-0" />
|
||||
</div>
|
||||
<div className="min-w-0 px-3 py-2.5">
|
||||
<div className="truncate text-sm font-black">
|
||||
|
||||
@@ -67,6 +67,7 @@ import {
|
||||
} from '../../services/match3d-works';
|
||||
import {
|
||||
createPuzzleAgentSession,
|
||||
executePuzzleAgentAction,
|
||||
getPuzzleAgentSession,
|
||||
} from '../../services/puzzle-agent';
|
||||
import {
|
||||
@@ -463,9 +464,17 @@ vi.mock('../puzzle-agent/PuzzleAgentWorkspace', () => ({
|
||||
|
||||
vi.mock('../puzzle-result/PuzzleResultView', () => ({
|
||||
PuzzleResultView: ({
|
||||
isBusy,
|
||||
onExecuteAction,
|
||||
session,
|
||||
onBack,
|
||||
}: {
|
||||
isBusy?: boolean;
|
||||
onExecuteAction: (payload: {
|
||||
action: string;
|
||||
levelId?: string;
|
||||
promptText?: string;
|
||||
}) => void;
|
||||
session: { draft?: { levelName: string } | null };
|
||||
onBack: () => void;
|
||||
}) => (
|
||||
@@ -475,6 +484,21 @@ vi.mock('../puzzle-result/PuzzleResultView', () => ({
|
||||
关卡名
|
||||
<input readOnly value={session.draft?.levelName ?? ''} />
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onExecuteAction({
|
||||
action: 'generate_puzzle_images',
|
||||
levelId: 'puzzle-level-1',
|
||||
promptText: '重新生成猫街',
|
||||
});
|
||||
}}
|
||||
>
|
||||
重新生成画面
|
||||
</button>
|
||||
<button type="button" disabled={isBusy}>
|
||||
新增关卡
|
||||
</button>
|
||||
<button type="button" onClick={onBack}>
|
||||
返回
|
||||
</button>
|
||||
@@ -550,14 +574,36 @@ vi.mock('../big-fish-result/BigFishResultView', () => ({
|
||||
vi.mock('../match3d-creation/Match3DAgentWorkspace', () => ({
|
||||
Match3DAgentWorkspace: ({
|
||||
session,
|
||||
onCreateFromForm,
|
||||
}: {
|
||||
session: { sessionId: string; messages: Array<{ text: string }> } | null;
|
||||
onCreateFromForm?: (payload: {
|
||||
seedText: string;
|
||||
themeText: string;
|
||||
referenceImageSrc: string | null;
|
||||
clearCount: number;
|
||||
difficulty: number;
|
||||
}) => void;
|
||||
}) => (
|
||||
<div className="match3d-agent-workspace-mock">
|
||||
<div>抓大鹅工作区:{session?.sessionId ?? 'missing-session'}</div>
|
||||
{session?.messages.map((message) => (
|
||||
<div key={`${session.sessionId}-${message.text}`}>{message.text}</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onCreateFromForm?.({
|
||||
seedText: '赛博水果摊题材,消除9次,难度6',
|
||||
themeText: '赛博水果摊',
|
||||
referenceImageSrc: null,
|
||||
clearCount: 9,
|
||||
difficulty: 6,
|
||||
});
|
||||
}}
|
||||
>
|
||||
生成抓大鹅草稿
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
@@ -2355,6 +2401,9 @@ test('create tab shows template tabs and embeds puzzle form by default', async (
|
||||
expect(
|
||||
screen.getByRole('tab', { name: 'AIRP' }).querySelector('img')?.src,
|
||||
).toContain('/creation-type-references/airp.webp');
|
||||
expect(
|
||||
screen.getByRole('tab', { name: '抓大鹅' }).querySelector('img')?.src,
|
||||
).toContain('/creation-type-references/match3d.webp');
|
||||
expect(
|
||||
screen.getByRole('tab', { name: '拼图' }).querySelector('.text-white'),
|
||||
).toBeTruthy();
|
||||
@@ -2364,12 +2413,29 @@ test('create tab shows template tabs and embeds puzzle form by default', async (
|
||||
expect(screen.queryByRole('button', { name: /智能创作/u })).toBeNull();
|
||||
expect(screen.queryByPlaceholderText('问一问百梦')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /角色扮演/u })).toBeNull();
|
||||
expect(screen.queryByRole('tab', { name: /抓大鹅/u })).toBeNull();
|
||||
expect(createRpgCreationSession).not.toHaveBeenCalled();
|
||||
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
|
||||
expect(createPuzzleAgentSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('create tab switches match3d into the embedded entry form', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreateTemplateHub(user);
|
||||
await user.click(screen.getByRole('tab', { name: '抓大鹅' }));
|
||||
|
||||
expect(
|
||||
screen.getByRole('tab', { name: '抓大鹅' }).getAttribute('aria-selected'),
|
||||
).toBe('true');
|
||||
expect(
|
||||
await screen.findByText('抓大鹅工作区:missing-session'),
|
||||
).toBeTruthy();
|
||||
expect(createPuzzleAgentSession).not.toHaveBeenCalled();
|
||||
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('embedded puzzle form routes through requireAuth while logged out', async () => {
|
||||
const user = userEvent.setup();
|
||||
const requireAuth = vi.fn();
|
||||
@@ -2826,6 +2892,159 @@ test('owned public puzzle detail edits original draft instead of remixing', asyn
|
||||
expect(getPuzzleAgentSession).toHaveBeenCalledWith('puzzle-session-1');
|
||||
expect(remixPuzzleGalleryWork).not.toHaveBeenCalled();
|
||||
expect(await screen.findByText('拼图结果页')).toBeTruthy();
|
||||
|
||||
vi.mocked(executePuzzleAgentAction).mockResolvedValueOnce({
|
||||
operation: {
|
||||
operationId: 'puzzle-image-generation-1',
|
||||
type: 'generate_puzzle_images',
|
||||
status: 'running',
|
||||
phaseLabel: '生成中',
|
||||
phaseDetail: '正在生成拼图画面',
|
||||
progress: 0.3,
|
||||
},
|
||||
session: {
|
||||
sessionId: 'puzzle-session-1',
|
||||
currentTurn: 3,
|
||||
progressPercent: 88,
|
||||
stage: 'ready_to_publish',
|
||||
anchorPack: {
|
||||
themePromise: {
|
||||
key: 'theme_promise',
|
||||
label: '主题承诺',
|
||||
value: '雨夜猫街',
|
||||
status: 'confirmed',
|
||||
},
|
||||
visualSubject: {
|
||||
key: 'visual_subject',
|
||||
label: '视觉主体',
|
||||
value: '屋檐下的猫',
|
||||
status: 'confirmed',
|
||||
},
|
||||
visualMood: {
|
||||
key: 'visual_mood',
|
||||
label: '视觉气质',
|
||||
value: '温暖',
|
||||
status: 'confirmed',
|
||||
},
|
||||
compositionHooks: {
|
||||
key: 'composition_hooks',
|
||||
label: '构图钩子',
|
||||
value: '雨滴与灯牌',
|
||||
status: 'confirmed',
|
||||
},
|
||||
tagsAndForbidden: {
|
||||
key: 'tags_and_forbidden',
|
||||
label: '标签与禁区',
|
||||
value: '猫咪、雨夜',
|
||||
status: 'confirmed',
|
||||
},
|
||||
},
|
||||
draft: {
|
||||
workTitle: '暖灯猫街作品',
|
||||
workDescription: '一套雨夜猫街主题拼图。',
|
||||
levelName: '雨夜猫街',
|
||||
summary: '屋檐下的猫与暖灯街角。',
|
||||
themeTags: ['猫咪', '雨夜', '暖灯'],
|
||||
forbiddenDirectives: [],
|
||||
creatorIntent: null,
|
||||
anchorPack: {
|
||||
themePromise: {
|
||||
key: 'theme_promise',
|
||||
label: '主题承诺',
|
||||
value: '雨夜猫街',
|
||||
status: 'confirmed',
|
||||
},
|
||||
visualSubject: {
|
||||
key: 'visual_subject',
|
||||
label: '视觉主体',
|
||||
value: '屋檐下的猫',
|
||||
status: 'confirmed',
|
||||
},
|
||||
visualMood: {
|
||||
key: 'visual_mood',
|
||||
label: '视觉气质',
|
||||
value: '温暖',
|
||||
status: 'confirmed',
|
||||
},
|
||||
compositionHooks: {
|
||||
key: 'composition_hooks',
|
||||
label: '构图钩子',
|
||||
value: '雨滴与灯牌',
|
||||
status: 'confirmed',
|
||||
},
|
||||
tagsAndForbidden: {
|
||||
key: 'tags_and_forbidden',
|
||||
label: '标签与禁区',
|
||||
value: '猫咪、雨夜',
|
||||
status: 'confirmed',
|
||||
},
|
||||
},
|
||||
candidates: [
|
||||
{
|
||||
candidateId: 'candidate-1',
|
||||
imageSrc: '/puzzle/candidate-1.png',
|
||||
assetId: 'asset-1',
|
||||
prompt: '雨夜猫咪',
|
||||
actualPrompt: null,
|
||||
sourceType: 'generated',
|
||||
selected: true,
|
||||
},
|
||||
],
|
||||
selectedCandidateId: 'candidate-1',
|
||||
coverImageSrc: '/puzzle/candidate-1.png',
|
||||
coverAssetId: 'asset-1',
|
||||
generationStatus: 'generating',
|
||||
levels: [
|
||||
{
|
||||
levelId: 'puzzle-level-1',
|
||||
levelName: '雨夜猫街',
|
||||
pictureDescription: '屋檐下的猫与暖灯街角。',
|
||||
pictureReference: null,
|
||||
candidates: [
|
||||
{
|
||||
candidateId: 'candidate-1',
|
||||
imageSrc: '/puzzle/candidate-1.png',
|
||||
assetId: 'asset-1',
|
||||
prompt: '雨夜猫咪',
|
||||
actualPrompt: null,
|
||||
sourceType: 'generated',
|
||||
selected: true,
|
||||
},
|
||||
],
|
||||
selectedCandidateId: 'candidate-1',
|
||||
coverImageSrc: '/puzzle/candidate-1.png',
|
||||
coverAssetId: 'asset-1',
|
||||
generationStatus: 'generating',
|
||||
},
|
||||
],
|
||||
metadata: null,
|
||||
},
|
||||
messages: [],
|
||||
lastAssistantReply: '大鱼结果页草稿已经生成,可以补正式资产。',
|
||||
publishedProfileId: null,
|
||||
suggestedActions: [],
|
||||
resultPreview: {
|
||||
draft: null,
|
||||
publishReady: false,
|
||||
blockers: [],
|
||||
qualityFindings: [],
|
||||
},
|
||||
updatedAt: '2026-04-26T10:10:00.000Z',
|
||||
},
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '重新生成画面' }));
|
||||
|
||||
expect(executePuzzleAgentAction).toHaveBeenCalledWith(
|
||||
'puzzle-session-1',
|
||||
expect.objectContaining({
|
||||
action: 'generate_puzzle_images',
|
||||
}),
|
||||
);
|
||||
expect(screen.getByRole('button', { name: '新增关卡' })).toHaveProperty(
|
||||
'disabled',
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('logged out public detail gates big fish start before local runtime', async () => {
|
||||
@@ -3027,7 +3246,6 @@ test('published puzzle works appear on home and mobile game category channel', a
|
||||
});
|
||||
|
||||
test('home recommendation starts embedded puzzle without global auth reset on local failure', async () => {
|
||||
const user = userEvent.setup();
|
||||
const publishedPuzzleWork = {
|
||||
workId: 'puzzle-work-public-1',
|
||||
profileId: 'puzzle-profile-public-1',
|
||||
@@ -3396,7 +3614,7 @@ test('embedded puzzle form timeout exits busy state and shows a readable error',
|
||||
expect(streamCreativeAgentMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('hidden match3d creation card stays closed even when public galleries fail', async () => {
|
||||
test('match3d creation tab stays usable even when public galleries fail', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
vi.mocked(listRpgEntryWorldGallery).mockRejectedValueOnce(
|
||||
@@ -3411,9 +3629,7 @@ test('hidden match3d creation card stays closed even when public galleries fail'
|
||||
await openCreateTemplateHub(user);
|
||||
expect(screen.queryByText('读取作品广场失败')).toBeNull();
|
||||
expect(screen.queryByText('读取抓大鹅广场失败')).toBeNull();
|
||||
expect(
|
||||
screen.queryByRole('tab', { name: /抓大鹅.*经典消除玩法/u }),
|
||||
).toBeNull();
|
||||
expect(screen.getByRole('tab', { name: '抓大鹅' })).toBeTruthy();
|
||||
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -1312,6 +1312,8 @@ test('logged in recommend runtime preloads adjacent work previews and drag switc
|
||||
vi.useFakeTimers();
|
||||
const onSelectNextRecommendEntry = vi.fn();
|
||||
const onSelectPreviousRecommendEntry = vi.fn();
|
||||
const onLikeRecommendEntry = vi.fn();
|
||||
const onRemixRecommendEntry = vi.fn();
|
||||
const firstEntry = {
|
||||
...puzzlePublicEntry,
|
||||
workId: 'puzzle-work-feed-1',
|
||||
@@ -1397,6 +1399,8 @@ test('logged in recommend runtime preloads adjacent work previews and drag switc
|
||||
activeRecommendEntryKey="puzzle:user-feed-1:puzzle-profile-feed-1"
|
||||
onSelectNextRecommendEntry={onSelectNextRecommendEntry}
|
||||
onSelectPreviousRecommendEntry={onSelectPreviousRecommendEntry}
|
||||
onLikeRecommendEntry={onLikeRecommendEntry}
|
||||
onRemixRecommendEntry={onRemixRecommendEntry}
|
||||
onOpenLibraryDetail={vi.fn()}
|
||||
onSearchPublicCode={vi.fn()}
|
||||
/>
|
||||
@@ -1412,8 +1416,38 @@ test('logged in recommend runtime preloads adjacent work previews and drag switc
|
||||
).toHaveLength(3);
|
||||
expect(screen.getAllByText('下一拼图').length).toBeGreaterThanOrEqual(2);
|
||||
expect(screen.getAllByText('上一拼图').length).toBeGreaterThanOrEqual(2);
|
||||
expect(screen.queryByText('评论')).toBeNull();
|
||||
expect(screen.queryByLabelText(/游玩/u)).toBeNull();
|
||||
const clipboardWriteText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText: clipboardWriteText },
|
||||
});
|
||||
|
||||
const meta = screen.getByLabelText('当前拼图 作品信息') as HTMLElement;
|
||||
const activeRecommendCard = within(meta);
|
||||
const likeButton = activeRecommendCard.getByRole('button', {
|
||||
name: '点赞 12',
|
||||
});
|
||||
expect(likeButton).toBeTruthy();
|
||||
expect(activeRecommendCard.getByLabelText('12 个赞')).toBeTruthy();
|
||||
const shareButton = activeRecommendCard.getByRole('button', { name: '分享' });
|
||||
const remixButton = activeRecommendCard.getByRole('button', {
|
||||
name: '改造 5',
|
||||
});
|
||||
expect(shareButton).toBeTruthy();
|
||||
expect(remixButton).toBeTruthy();
|
||||
|
||||
fireEvent.click(likeButton);
|
||||
fireEvent.click(shareButton);
|
||||
fireEvent.click(remixButton);
|
||||
|
||||
expect(onLikeRecommendEntry).toHaveBeenCalledWith(firstEntry);
|
||||
expect(onRemixRecommendEntry).toHaveBeenCalledWith(firstEntry);
|
||||
expect(clipboardWriteText).toHaveBeenCalledWith(
|
||||
expect.stringContaining('作品号:PZ-FEED1'),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
dispatchPointerEvent(meta, 'pointerdown', { pointerId: 1, clientY: 300 });
|
||||
dispatchPointerEvent(meta, 'pointermove', { pointerId: 1, clientY: 210 });
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Coins,
|
||||
Compass,
|
||||
Copy,
|
||||
GitFork,
|
||||
Gamepad2,
|
||||
Heart,
|
||||
LogIn,
|
||||
@@ -16,10 +17,12 @@ import {
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
Share2,
|
||||
Settings,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
Star,
|
||||
ThumbsUp,
|
||||
Ticket,
|
||||
UserPlus,
|
||||
UserRound,
|
||||
@@ -54,6 +57,7 @@ import type {
|
||||
RedeemProfileRewardCodeResponse,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import { buildPublicWorkDetailUrl } from '../../routing/appPageRoutes';
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
import {
|
||||
getPublicAuthUserByCode,
|
||||
@@ -86,6 +90,7 @@ import {
|
||||
isVisualNovelGalleryEntry,
|
||||
type PlatformPublicGalleryCard,
|
||||
type PlatformWorldCardLike,
|
||||
resolvePlatformPublicWorkCode,
|
||||
resolvePlatformWorldCoverImage,
|
||||
resolvePlatformWorldCoverSlides,
|
||||
resolvePlatformWorldLeadPortrait,
|
||||
@@ -126,6 +131,8 @@ export interface RpgEntryHomeViewProps {
|
||||
recommendRuntimeError?: string | null;
|
||||
onSelectNextRecommendEntry?: () => void;
|
||||
onSelectPreviousRecommendEntry?: () => void;
|
||||
onLikeRecommendEntry?: (entry: PlatformPublicGalleryCard) => void;
|
||||
onRemixRecommendEntry?: (entry: PlatformPublicGalleryCard) => void;
|
||||
onOpenLibraryDetail: (
|
||||
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
|
||||
) => void;
|
||||
@@ -772,19 +779,27 @@ function RecommendSwipeCard({
|
||||
authorAvatarUrl,
|
||||
isActive,
|
||||
visual,
|
||||
shareState,
|
||||
onDragPointerDown,
|
||||
onDragPointerMove,
|
||||
onDragPointerUp,
|
||||
onDragPointerCancel,
|
||||
onLike,
|
||||
onShare,
|
||||
onRemix,
|
||||
}: {
|
||||
entry: PlatformPublicGalleryCard;
|
||||
authorAvatarUrl?: string | null;
|
||||
isActive: boolean;
|
||||
visual: ReactNode;
|
||||
shareState?: 'idle' | 'copied' | 'failed';
|
||||
onDragPointerDown?: (event: PointerEvent<HTMLElement>) => void;
|
||||
onDragPointerMove?: (event: PointerEvent<HTMLElement>) => void;
|
||||
onDragPointerUp?: (event: PointerEvent<HTMLElement>) => void;
|
||||
onDragPointerCancel?: (event: PointerEvent<HTMLElement>) => void;
|
||||
onLike?: () => void;
|
||||
onShare?: () => void;
|
||||
onRemix?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
@@ -797,10 +812,14 @@ function RecommendSwipeCard({
|
||||
entry={entry}
|
||||
authorAvatarUrl={authorAvatarUrl}
|
||||
isActive={isActive}
|
||||
shareState={shareState}
|
||||
onDragPointerDown={onDragPointerDown}
|
||||
onDragPointerMove={onDragPointerMove}
|
||||
onDragPointerUp={onDragPointerUp}
|
||||
onDragPointerCancel={onDragPointerCancel}
|
||||
onLike={onLike}
|
||||
onShare={onShare}
|
||||
onRemix={onRemix}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -814,6 +833,10 @@ function RecommendRuntimeMeta({
|
||||
onDragPointerMove,
|
||||
onDragPointerUp,
|
||||
onDragPointerCancel,
|
||||
shareState = 'idle',
|
||||
onLike,
|
||||
onShare,
|
||||
onRemix,
|
||||
isActive = true,
|
||||
}: {
|
||||
entry: PlatformPublicGalleryCard;
|
||||
@@ -822,20 +845,21 @@ function RecommendRuntimeMeta({
|
||||
onDragPointerMove?: (event: PointerEvent<HTMLElement>) => void;
|
||||
onDragPointerUp?: (event: PointerEvent<HTMLElement>) => void;
|
||||
onDragPointerCancel?: (event: PointerEvent<HTMLElement>) => void;
|
||||
shareState?: 'idle' | 'copied' | 'failed';
|
||||
onLike?: () => void;
|
||||
onShare?: () => void;
|
||||
onRemix?: () => void;
|
||||
isActive?: boolean;
|
||||
}) {
|
||||
const playCount = getPlatformWorldPlayCount(entry);
|
||||
const remixCount = getPlatformWorldRemixCount(entry);
|
||||
const likeCount = getPlatformWorldLikeCount(entry);
|
||||
const remixCount = getPlatformWorldRemixCount(entry);
|
||||
const authorName = entry.authorDisplayName.trim() || '玩家';
|
||||
const authorAvatarLabel = getPublicAuthorAvatarLabel(authorName);
|
||||
const normalizedAuthorAvatarUrl = authorAvatarUrl?.trim() ?? '';
|
||||
const displayName = formatPlatformWorkDisplayName(entry.worldName);
|
||||
const statItems = [
|
||||
{ label: '游玩', value: playCount, icon: Gamepad2 },
|
||||
{ label: '点赞', value: likeCount, icon: Heart },
|
||||
{ label: '改造', value: remixCount, icon: MessageCircle },
|
||||
];
|
||||
const stopActionPointer = (event: PointerEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -854,19 +878,6 @@ function RecommendRuntimeMeta({
|
||||
onPointerUp={onDragPointerUp}
|
||||
onPointerCancel={onDragPointerCancel}
|
||||
>
|
||||
<div className="platform-recommend-work-meta__stats">
|
||||
{statItems.map(({ label, value, icon: Icon }) => (
|
||||
<span
|
||||
key={label}
|
||||
className="platform-recommend-work-meta__stat"
|
||||
aria-label={`${label} ${formatCompactCount(value)}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" aria-hidden="true" />
|
||||
<span>{formatCompactCount(value)}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="platform-recommend-work-meta__row">
|
||||
<div
|
||||
className="platform-recommend-work-meta__identity"
|
||||
@@ -894,6 +905,62 @@ function RecommendRuntimeMeta({
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="platform-recommend-work-meta__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="platform-recommend-work-meta__action platform-recommend-work-meta__action--icon platform-recommend-work-meta__action--like"
|
||||
onPointerDown={stopActionPointer}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onLike?.();
|
||||
}}
|
||||
disabled={!isActive || !onLike}
|
||||
aria-label={`点赞 ${formatCompactCount(likeCount)}`}
|
||||
title="点赞"
|
||||
>
|
||||
<ThumbsUp className="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
<span
|
||||
className="platform-recommend-work-meta__like-count"
|
||||
aria-label={`${formatCompactCount(likeCount)} 个赞`}
|
||||
>
|
||||
{formatCompactCount(likeCount)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="platform-recommend-work-meta__action platform-recommend-work-meta__action--icon"
|
||||
onPointerDown={stopActionPointer}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onShare?.();
|
||||
}}
|
||||
disabled={!isActive || !onShare}
|
||||
aria-label={
|
||||
shareState === 'copied'
|
||||
? '分享内容已复制'
|
||||
: shareState === 'failed'
|
||||
? '分享内容复制失败'
|
||||
: '分享'
|
||||
}
|
||||
title="分享"
|
||||
>
|
||||
<Share2 className="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="platform-recommend-work-meta__action platform-recommend-work-meta__action--icon platform-recommend-work-meta__action--remix"
|
||||
onPointerDown={stopActionPointer}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onRemix?.();
|
||||
}}
|
||||
disabled={!isActive || !onRemix}
|
||||
aria-label={`改造 ${formatCompactCount(remixCount)}`}
|
||||
title="改造"
|
||||
>
|
||||
<GitFork className="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@@ -2977,6 +3044,8 @@ export function RpgEntryHomeView({
|
||||
recommendRuntimeError = null,
|
||||
onSelectNextRecommendEntry,
|
||||
onSelectPreviousRecommendEntry,
|
||||
onLikeRecommendEntry,
|
||||
onRemixRecommendEntry,
|
||||
onOpenLibraryDetail,
|
||||
onDeleteLibraryEntry,
|
||||
deletingLibraryEntryId = null,
|
||||
@@ -3863,6 +3932,10 @@ export function RpgEntryHomeView({
|
||||
const [recommendDragOffsetY, setRecommendDragOffsetY] = useState(0);
|
||||
const [recommendDragCommitDirection, setRecommendDragCommitDirection] =
|
||||
useState<1 | -1 | null>(null);
|
||||
const [recommendShareState, setRecommendShareState] = useState<
|
||||
'idle' | 'copied' | 'failed'
|
||||
>('idle');
|
||||
const recommendShareResetTimerRef = useRef<number | null>(null);
|
||||
const recommendCardStageRef = useRef<HTMLDivElement | null>(null);
|
||||
const recommendDragStartRef = useRef<{
|
||||
pointerId: number;
|
||||
@@ -4005,6 +4078,36 @@ export function RpgEntryHomeView({
|
||||
onSelectNextRecommendEntry,
|
||||
recommendedFeedEntries.length,
|
||||
]);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (recommendShareResetTimerRef.current !== null) {
|
||||
window.clearTimeout(recommendShareResetTimerRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
useEffect(() => {
|
||||
setRecommendShareState('idle');
|
||||
}, [activeRecommendEntryKey]);
|
||||
const shareRecommendEntry = useCallback((entry: PlatformPublicGalleryCard) => {
|
||||
const publicWorkCode = resolvePlatformPublicWorkCode(entry)?.trim();
|
||||
if (!publicWorkCode) {
|
||||
setRecommendShareState('failed');
|
||||
return;
|
||||
}
|
||||
|
||||
const shareText = `邀请你来玩《${entry.worldName}》\n作品号:${publicWorkCode}\n${buildPublicWorkDetailUrl(publicWorkCode)}`;
|
||||
void copyTextToClipboard(shareText).then((copied) => {
|
||||
setRecommendShareState(copied ? 'copied' : 'failed');
|
||||
if (recommendShareResetTimerRef.current !== null) {
|
||||
window.clearTimeout(recommendShareResetTimerRef.current);
|
||||
}
|
||||
recommendShareResetTimerRef.current = window.setTimeout(() => {
|
||||
recommendShareResetTimerRef.current = null;
|
||||
setRecommendShareState('idle');
|
||||
}, 1400);
|
||||
});
|
||||
}, []);
|
||||
const openActiveRecommendEntry = useCallback(() => {
|
||||
if (!activeRecommendEntry) {
|
||||
return;
|
||||
@@ -4168,6 +4271,10 @@ export function RpgEntryHomeView({
|
||||
onDragPointerMove={moveRecommendDrag}
|
||||
onDragPointerUp={endRecommendDrag}
|
||||
onDragPointerCancel={cancelRecommendDrag}
|
||||
shareState={recommendShareState}
|
||||
onLike={() => onLikeRecommendEntry?.(activeRecommendEntry)}
|
||||
onShare={() => shareRecommendEntry(activeRecommendEntry)}
|
||||
onRemix={() => onRemixRecommendEntry?.(activeRecommendEntry)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,123 +1,101 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import type { ComponentProps } from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import { buildVisualNovelForbiddenCopyPattern } from '../visual-novel-runtime/visualNovelForbiddenCopy';
|
||||
import { mockVisualNovelSession } from '../visual-novel-runtime/visualNovelMockData';
|
||||
import { VisualNovelAgentWorkspace } from './VisualNovelAgentWorkspace';
|
||||
import { AuthUiContext } from '../auth/AuthUiContext';
|
||||
import {
|
||||
buildVisualNovelEntryGenerationAnchorEntries,
|
||||
buildVisualNovelEntryGenerationProgress,
|
||||
VisualNovelAgentWorkspace,
|
||||
} from './VisualNovelAgentWorkspace';
|
||||
|
||||
vi.mock('../../services/creation-agent/creationAgentDocumentInput', () => ({
|
||||
parseCreationAgentDocumentInput: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/visual-novel-creation', () => ({
|
||||
uploadVisualNovelAsset: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedParseCreationAgentDocumentInput = vi.mocked(
|
||||
await import('../../services/creation-agent/creationAgentDocumentInput'),
|
||||
);
|
||||
const mockedVisualNovelAssetClient = vi.mocked(
|
||||
await import('../../services/visual-novel-creation'),
|
||||
);
|
||||
|
||||
function renderWorkspace(ui?: Partial<ComponentProps<typeof VisualNovelAgentWorkspace>>) {
|
||||
function renderWorkspace(
|
||||
ui?: Partial<ComponentProps<typeof VisualNovelAgentWorkspace>>,
|
||||
) {
|
||||
return render(
|
||||
<AuthUiContext.Provider
|
||||
value={
|
||||
{
|
||||
user: { id: 'user-1' },
|
||||
platformTheme: 'light',
|
||||
} as never
|
||||
}
|
||||
>
|
||||
<VisualNovelAgentWorkspace
|
||||
session={mockVisualNovelSession}
|
||||
onBack={() => {}}
|
||||
{...ui}
|
||||
/>
|
||||
</AuthUiContext.Provider>,
|
||||
<VisualNovelAgentWorkspace onBack={() => {}} session={null} {...ui} />,
|
||||
);
|
||||
}
|
||||
|
||||
test('visual novel workspace renders mock creation shell without forbidden entry', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenResult = vi.fn();
|
||||
test('visual novel workspace only exposes one-line input and visual style entry', () => {
|
||||
const onCreateFromForm = vi.fn();
|
||||
|
||||
renderWorkspace({ onOpenResult });
|
||||
renderWorkspace({ onCreateFromForm });
|
||||
|
||||
expect(screen.getByRole('heading', { name: '视觉小说' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '一句话' })).toBeTruthy();
|
||||
expect(screen.getByLabelText('一句话创作')).toBeTruthy();
|
||||
expect(screen.getByText('视觉画风')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '映画动画' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '水彩绘本' })).toBeTruthy();
|
||||
expect(screen.getByText('消耗20光点')).toBeTruthy();
|
||||
expect(screen.queryByText(buildVisualNovelForbiddenCopyPattern())).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '进入结果页' }));
|
||||
|
||||
expect(onOpenResult).toHaveBeenCalledWith(mockVisualNovelSession);
|
||||
expect(screen.queryByRole('button', { name: '文档' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '空白' })).toBeNull();
|
||||
expect(screen.queryByLabelText('上传文档')).toBeNull();
|
||||
expect(screen.queryByText('进入结果页')).toBeNull();
|
||||
expect(screen.queryByText('Agent')).toBeNull();
|
||||
});
|
||||
|
||||
test('visual novel workspace opens editable blank draft from blank source', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenResult = vi.fn();
|
||||
test('visual novel workspace submits idea and selected visual style as seed text', () => {
|
||||
const onCreateFromForm = vi.fn();
|
||||
|
||||
renderWorkspace({ session: null, onOpenResult });
|
||||
renderWorkspace({ onCreateFromForm });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '空白' }));
|
||||
const openResultButtons = screen.getAllByRole('button', {
|
||||
name: '进入结果页',
|
||||
fireEvent.change(screen.getByLabelText('一句话创作'), {
|
||||
target: { value: '失忆画师在雨夜剧场寻找旧胶片。' },
|
||||
});
|
||||
await user.click(openResultButtons[0]!);
|
||||
fireEvent.click(screen.getByRole('button', { name: '像素霓虹' }));
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: /生成视觉小说草稿/u }),
|
||||
);
|
||||
|
||||
expect(onOpenResult).toHaveBeenCalledTimes(1);
|
||||
const session = onOpenResult.mock.calls[0]?.[0];
|
||||
expect(session?.sourceMode).toBe('blank');
|
||||
expect(session?.draft?.sourceMode).toBe('blank');
|
||||
expect(session?.draft?.runtimeConfig.textModeEnabled).toBe(true);
|
||||
expect(onCreateFromForm).toHaveBeenCalledWith({
|
||||
sourceMode: 'idea',
|
||||
sourceAssetIds: [],
|
||||
ideaText: '失忆画师在雨夜剧场寻找旧胶片。',
|
||||
visualStyleId: 'pixel-noir',
|
||||
visualStyleLabel: '像素霓虹',
|
||||
visualStylePrompt:
|
||||
'高可读像素视觉小说画风,霓虹反差、硬朗轮廓和复古界面气质。',
|
||||
seedText:
|
||||
'失忆画师在雨夜剧场寻找旧胶片。\n视觉画风:像素霓虹\n画风要求:高可读像素视觉小说画风,霓虹反差、硬朗轮廓和复古界面气质。',
|
||||
});
|
||||
});
|
||||
|
||||
test('visual novel workspace uploads document asset and passes asset id to session', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCreateSession = vi.fn();
|
||||
const parseMock = mockedParseCreationAgentDocumentInput.parseCreationAgentDocumentInput;
|
||||
const uploadMock = mockedVisualNovelAssetClient.uploadVisualNovelAsset;
|
||||
test('visual novel workspace restores idea text from existing session', () => {
|
||||
renderWorkspace({ session: mockVisualNovelSession });
|
||||
|
||||
parseMock.mockResolvedValue({
|
||||
document: {
|
||||
fileName: '世界设定.docx',
|
||||
contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
sizeBytes: 128,
|
||||
text: '第一章\n雨夜书店\n第二章\n失踪乘客',
|
||||
sourceAssetId: 'asset-doc-source',
|
||||
},
|
||||
});
|
||||
uploadMock.mockResolvedValue({
|
||||
assetObjectId: 'asset-doc-1',
|
||||
assetKind: 'visual_novel_document',
|
||||
objectKey: 'generated-character-drafts/visual-novel/draft/document/1.docx',
|
||||
imageSrc: '/generated-character-drafts/visual-novel/draft/document/1.docx',
|
||||
});
|
||||
|
||||
renderWorkspace({ session: null, onCreateSession });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '文档' }));
|
||||
const fileInput = screen.getByLabelText('上传文档') as HTMLInputElement;
|
||||
const file = new File(['first chapter'], '世界设定.docx', {
|
||||
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
});
|
||||
await user.upload(fileInput, file);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '生成底稿' }));
|
||||
|
||||
expect(parseMock).toHaveBeenCalledTimes(1);
|
||||
expect(uploadMock).toHaveBeenCalledTimes(1);
|
||||
expect(onCreateSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sourceMode: 'document',
|
||||
sourceAssetIds: ['asset-doc-1'],
|
||||
seedText: expect.stringContaining('第一章'),
|
||||
}),
|
||||
expect((screen.getByLabelText('一句话创作') as HTMLTextAreaElement).value).toBe(
|
||||
'想做一个雪夜列车和旧电台有关的悬疑视觉小说。',
|
||||
);
|
||||
});
|
||||
|
||||
test('visual novel generation helpers build process page data', () => {
|
||||
const payload = {
|
||||
sourceMode: 'idea' as const,
|
||||
seedText:
|
||||
'雨夜书店\n视觉画风:水彩绘本\n画风要求:透明水彩与绘本质感。',
|
||||
sourceAssetIds: [],
|
||||
ideaText: '雨夜书店',
|
||||
visualStyleId: 'watercolor' as const,
|
||||
visualStyleLabel: '水彩绘本',
|
||||
visualStylePrompt: '透明水彩与绘本质感。',
|
||||
};
|
||||
|
||||
expect(buildVisualNovelEntryGenerationAnchorEntries(payload)).toEqual([
|
||||
{ id: 'visual-novel-idea', label: '一句话', value: '雨夜书店' },
|
||||
{ id: 'visual-novel-style', label: '视觉画风', value: '水彩绘本' },
|
||||
]);
|
||||
|
||||
const progress = buildVisualNovelEntryGenerationProgress(
|
||||
1_000,
|
||||
'generating',
|
||||
8_000,
|
||||
);
|
||||
|
||||
expect(progress.phaseId).toBe('generating');
|
||||
expect(progress.overallProgress).toBeGreaterThan(0);
|
||||
expect(progress.steps.some((step) => step.status === 'active')).toBe(true);
|
||||
});
|
||||
|
||||
@@ -1,70 +1,146 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
FileText,
|
||||
Loader2,
|
||||
PenLine,
|
||||
Upload,
|
||||
Send,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { type ChangeEvent, useMemo, useRef, useState } from 'react';
|
||||
import { ArrowLeft, Loader2, Sparkles, WandSparkles } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { CustomWorldGenerationProgress } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type {
|
||||
CreateVisualNovelSessionRequest,
|
||||
ExecuteVisualNovelAgentActionRequest,
|
||||
SendVisualNovelMessageRequest,
|
||||
VisualNovelAgentSessionSnapshot,
|
||||
VisualNovelSourceMode,
|
||||
} from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { parseCreationAgentDocumentInput } from '../../services/creation-agent/creationAgentDocumentInput';
|
||||
import { uploadVisualNovelAsset } from '../../services/visual-novel-creation';
|
||||
import {
|
||||
createBlankVisualNovelDraft,
|
||||
createMockVisualNovelSessionFromDraft,
|
||||
mockVisualNovelSession,
|
||||
} from '../visual-novel-runtime/visualNovelMockData';
|
||||
import type { CustomWorldStructuredAnchorEntry } from '../../services/customWorldAgentGenerationProgress';
|
||||
|
||||
type VisualNovelAgentWorkspaceProps = {
|
||||
session?: VisualNovelAgentSessionSnapshot | null;
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
streamingReplyText?: string;
|
||||
onBack: () => void;
|
||||
onCreateSession?: (payload: CreateVisualNovelSessionRequest) => void;
|
||||
onSubmitMessage?: (payload: SendVisualNovelMessageRequest) => void;
|
||||
onExecuteAction?: (payload: ExecuteVisualNovelAgentActionRequest) => void;
|
||||
onOpenResult?: (session: VisualNovelAgentSessionSnapshot) => void;
|
||||
onCreateFromForm?: (payload: VisualNovelEntryFormPayload) => void;
|
||||
initialFormPayload?: VisualNovelEntryFormPayload | null;
|
||||
showBackButton?: boolean;
|
||||
title?: string | null;
|
||||
};
|
||||
|
||||
const SOURCE_OPTIONS: Array<{
|
||||
id: VisualNovelSourceMode;
|
||||
label: string;
|
||||
icon: typeof PenLine;
|
||||
}> = [
|
||||
{ id: 'idea', label: '一句话', icon: Sparkles },
|
||||
{ id: 'document', label: '文档', icon: FileText },
|
||||
{ id: 'blank', label: '空白', icon: PenLine },
|
||||
];
|
||||
export type VisualNovelEntryFormPayload = Omit<
|
||||
CreateVisualNovelSessionRequest,
|
||||
'seedText' | 'sourceMode' | 'sourceAssetIds'
|
||||
> & {
|
||||
sourceMode: 'idea';
|
||||
seedText: string;
|
||||
sourceAssetIds: string[];
|
||||
ideaText: string;
|
||||
visualStyleId: VisualNovelStyleOptionId;
|
||||
visualStyleLabel: string;
|
||||
visualStylePrompt: string;
|
||||
};
|
||||
|
||||
function buildClientMessageId() {
|
||||
return `vn-message-${Date.now()}-${Math.round(Math.random() * 1_000_000)}`;
|
||||
type VisualNovelFormState = {
|
||||
ideaText: string;
|
||||
visualStyleId: VisualNovelStyleOptionId;
|
||||
};
|
||||
|
||||
const VISUAL_NOVEL_STYLE_OPTIONS = [
|
||||
{
|
||||
id: 'cinematic-anime',
|
||||
label: '映画动画',
|
||||
prompt: '电影感动画视觉小说画风,光影层次清晰,角色立绘精致,背景有景深。',
|
||||
},
|
||||
{
|
||||
id: 'watercolor',
|
||||
label: '水彩绘本',
|
||||
prompt: '透明水彩与绘本质感,色彩柔和,边缘带手绘晕染,适合温柔叙事。',
|
||||
},
|
||||
{
|
||||
id: 'pixel-noir',
|
||||
label: '像素霓虹',
|
||||
prompt: '高可读像素视觉小说画风,霓虹反差、硬朗轮廓和复古界面气质。',
|
||||
},
|
||||
{
|
||||
id: 'ink-fantasy',
|
||||
label: '水墨幻想',
|
||||
prompt: '东方水墨幻想画风,留白、墨色层次和淡彩点染并重。',
|
||||
},
|
||||
{
|
||||
id: 'soft-pastel',
|
||||
label: '柔彩校园',
|
||||
prompt: '柔和粉彩校园画风,干净明亮,角色表情细腻,日常氛围轻盈。',
|
||||
},
|
||||
{
|
||||
id: 'dark-gothic',
|
||||
label: '暗色哥特',
|
||||
prompt: '暗色哥特视觉小说画风,深色场景、烛光高光和华丽服装细节。',
|
||||
},
|
||||
] as const;
|
||||
|
||||
type VisualNovelStyleOptionId =
|
||||
(typeof VISUAL_NOVEL_STYLE_OPTIONS)[number]['id'];
|
||||
|
||||
const EMPTY_FORM_STATE: VisualNovelFormState = {
|
||||
ideaText: '',
|
||||
visualStyleId: 'cinematic-anime',
|
||||
};
|
||||
|
||||
function getVisualNovelStyleOption(optionId: VisualNovelStyleOptionId) {
|
||||
return (
|
||||
VISUAL_NOVEL_STYLE_OPTIONS.find((option) => option.id === optionId) ??
|
||||
VISUAL_NOVEL_STYLE_OPTIONS[0]
|
||||
);
|
||||
}
|
||||
|
||||
function clampDocumentSeedText(value: string) {
|
||||
return value.trim().replace(/\s+/gu, ' ').slice(0, 4000);
|
||||
function resolveStyleOptionId(
|
||||
value: string | null | undefined,
|
||||
): VisualNovelStyleOptionId {
|
||||
return (
|
||||
VISUAL_NOVEL_STYLE_OPTIONS.find((option) => option.id === value)?.id ??
|
||||
'cinematic-anime'
|
||||
);
|
||||
}
|
||||
|
||||
function VisualNovelSourceButton({
|
||||
function resolveIdeaTextFromSession(
|
||||
session: VisualNovelAgentSessionSnapshot | null | undefined,
|
||||
) {
|
||||
const userText =
|
||||
session?.messages.find((message) => message.role === 'user')?.text ?? '';
|
||||
return userText
|
||||
.replace(/视觉画风[::][^\n]*(\n|$)/u, '')
|
||||
.replace(/画风要求[::][^\n]*(\n|$)/u, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function resolveInitialFormState(
|
||||
session: VisualNovelAgentSessionSnapshot | null | undefined,
|
||||
initialFormPayload: VisualNovelEntryFormPayload | null = null,
|
||||
): VisualNovelFormState {
|
||||
return {
|
||||
...EMPTY_FORM_STATE,
|
||||
ideaText:
|
||||
initialFormPayload?.ideaText?.trim() ||
|
||||
resolveIdeaTextFromSession(session) ||
|
||||
'',
|
||||
visualStyleId: resolveStyleOptionId(initialFormPayload?.visualStyleId),
|
||||
};
|
||||
}
|
||||
|
||||
function buildVisualNovelSeedText(
|
||||
ideaText: string,
|
||||
visualStyleLabel: string,
|
||||
visualStylePrompt: string,
|
||||
) {
|
||||
return [
|
||||
ideaText.trim(),
|
||||
`视觉画风:${visualStyleLabel}`,
|
||||
`画风要求:${visualStylePrompt}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function VisualNovelStyleButton({
|
||||
active,
|
||||
disabled,
|
||||
icon: Icon,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean;
|
||||
disabled: boolean;
|
||||
icon: typeof PenLine;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
@@ -73,353 +149,349 @@ function VisualNovelSourceButton({
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-pressed={active}
|
||||
aria-label={label}
|
||||
onClick={onClick}
|
||||
className={`flex min-h-16 min-w-0 items-center gap-3 rounded-[1.1rem] border px-3 text-left transition ${
|
||||
className={`relative h-[4.45rem] w-[5.2rem] shrink-0 snap-start overflow-hidden rounded-[0.9rem] border p-0 text-left transition sm:h-[5.2rem] sm:w-[6.1rem] ${
|
||||
active
|
||||
? 'border-[var(--platform-button-primary-border)] bg-[var(--platform-nav-active-fill)] text-[var(--platform-text-strong)]'
|
||||
: 'border-[var(--platform-subpanel-border)] bg-white/72 text-[var(--platform-text-base)] hover:bg-white'
|
||||
? 'border-[#ff4056] ring-1 ring-inset ring-[#ff4056]'
|
||||
: 'border-[var(--platform-subpanel-border)]'
|
||||
} ${disabled ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
>
|
||||
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-white/80">
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="absolute inset-0 bg-[linear-gradient(135deg,rgba(255,255,255,0.98),rgba(244,247,255,0.9))]" />
|
||||
<span className="absolute inset-0 bg-[radial-gradient(circle_at_30%_20%,rgba(255,255,255,0.95),transparent_28%),linear-gradient(135deg,rgba(255,64,86,0.18),rgba(56,189,248,0.18))]" />
|
||||
<span className="absolute inset-0 bg-[linear-gradient(180deg,rgba(3,7,18,0.02)_0%,rgba(3,7,18,0.1)_42%,rgba(3,7,18,0.82)_100%)]" />
|
||||
<span className="absolute inset-x-2 bottom-1.5 truncate rounded-full bg-black/26 px-1.5 py-0.5 text-center text-[11px] font-black text-white [text-shadow:0_1px_6px_rgba(0,0,0,0.9)]">
|
||||
{label}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-sm font-black">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function VisualNovelAgentWorkspace({
|
||||
session = mockVisualNovelSession,
|
||||
session = null,
|
||||
isBusy = false,
|
||||
error = null,
|
||||
streamingReplyText = '',
|
||||
onBack,
|
||||
onCreateSession,
|
||||
onSubmitMessage,
|
||||
onExecuteAction,
|
||||
onOpenResult,
|
||||
onCreateFromForm,
|
||||
initialFormPayload = null,
|
||||
showBackButton = true,
|
||||
title = null,
|
||||
}: VisualNovelAgentWorkspaceProps) {
|
||||
const [sourceMode, setSourceMode] = useState<VisualNovelSourceMode>(
|
||||
session?.sourceMode ?? 'idea',
|
||||
const [formState, setFormState] = useState<VisualNovelFormState>(() =>
|
||||
resolveInitialFormState(session, initialFormPayload),
|
||||
);
|
||||
const [seedText, setSeedText] = useState(
|
||||
session?.messages.find((message) => message.role === 'user')?.text ?? '',
|
||||
const appliedInitialFormKeyRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const nextInitialFormKey =
|
||||
session?.sessionId ?? JSON.stringify(initialFormPayload ?? null);
|
||||
if (appliedInitialFormKeyRef.current === nextInitialFormKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
appliedInitialFormKeyRef.current = nextInitialFormKey;
|
||||
setFormState(resolveInitialFormState(session, initialFormPayload));
|
||||
}, [initialFormPayload, session]);
|
||||
|
||||
const ideaText = formState.ideaText.trim();
|
||||
const selectedStyleOption = getVisualNovelStyleOption(
|
||||
formState.visualStyleId,
|
||||
);
|
||||
const [documentAssetId, setDocumentAssetId] = useState(
|
||||
session?.draft?.sourceAssetIds[0] ?? '',
|
||||
);
|
||||
const [documentAssetLabel, setDocumentAssetLabel] = useState('');
|
||||
const [documentUploadError, setDocumentUploadError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [isDocumentUploading, setIsDocumentUploading] = useState(false);
|
||||
const [messageText, setMessageText] = useState('');
|
||||
const displaySession = session ?? mockVisualNovelSession;
|
||||
const draft = displaySession.draft;
|
||||
const authUi = useAuthUi();
|
||||
const documentFileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const canStart =
|
||||
!isBusy &&
|
||||
((sourceMode === 'blank') ||
|
||||
(sourceMode === 'idea' && Boolean(seedText.trim())) ||
|
||||
(sourceMode === 'document' && Boolean(documentAssetId.trim())));
|
||||
const canSend = !isBusy && messageText.trim();
|
||||
const pendingAction = displaySession.pendingAction;
|
||||
const progressItems = useMemo(
|
||||
() => [
|
||||
{ label: '世界观', value: draft?.world.title || '-' },
|
||||
{ label: '角色', value: `${draft?.characters.length ?? 0}` },
|
||||
{ label: '场景', value: `${draft?.scenes.length ?? 0}` },
|
||||
{ label: '阶段', value: `${draft?.storyPhases.length ?? 0}` },
|
||||
],
|
||||
[draft],
|
||||
const formPayload = useMemo<VisualNovelEntryFormPayload>(
|
||||
() => ({
|
||||
sourceMode: 'idea',
|
||||
seedText: buildVisualNovelSeedText(
|
||||
ideaText,
|
||||
selectedStyleOption.label,
|
||||
selectedStyleOption.prompt,
|
||||
),
|
||||
sourceAssetIds: [],
|
||||
ideaText,
|
||||
visualStyleId: selectedStyleOption.id,
|
||||
visualStyleLabel: selectedStyleOption.label,
|
||||
visualStylePrompt: selectedStyleOption.prompt,
|
||||
}),
|
||||
[ideaText, selectedStyleOption],
|
||||
);
|
||||
const canSubmit = Boolean(ideaText && !isBusy);
|
||||
|
||||
const startDraft = () => {
|
||||
if (!canStart) {
|
||||
if (!canSubmit) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sourceMode === 'blank') {
|
||||
const blankSession = createMockVisualNovelSessionFromDraft(
|
||||
createBlankVisualNovelDraft('blank'),
|
||||
);
|
||||
onOpenResult?.(blankSession);
|
||||
return;
|
||||
}
|
||||
|
||||
onCreateSession?.({
|
||||
sourceMode,
|
||||
seedText: seedText.trim() || null,
|
||||
sourceAssetIds:
|
||||
sourceMode === 'document' && documentAssetId.trim()
|
||||
? [documentAssetId.trim()]
|
||||
: [],
|
||||
});
|
||||
};
|
||||
|
||||
const handleDocumentFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.currentTarget.value = '';
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDocumentUploading(true);
|
||||
setDocumentUploadError(null);
|
||||
try {
|
||||
const parsed = await parseCreationAgentDocumentInput(file);
|
||||
const uploadedAsset = await uploadVisualNovelAsset({
|
||||
kind: 'document',
|
||||
file,
|
||||
ownerUserId: authUi?.user?.id ?? null,
|
||||
});
|
||||
setDocumentAssetId(uploadedAsset.assetObjectId);
|
||||
setDocumentAssetLabel(file.name.trim() || parsed.document.fileName);
|
||||
setSeedText(clampDocumentSeedText(parsed.document.text));
|
||||
} catch (uploadError) {
|
||||
setDocumentUploadError(
|
||||
uploadError instanceof Error
|
||||
? uploadError.message
|
||||
: '文档上传失败,请稍后重试。',
|
||||
);
|
||||
} finally {
|
||||
setIsDocumentUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submitMessage = () => {
|
||||
const text = messageText.trim();
|
||||
if (!text || isBusy) {
|
||||
return;
|
||||
}
|
||||
onSubmitMessage?.({
|
||||
clientMessageId: buildClientMessageId(),
|
||||
text,
|
||||
});
|
||||
setMessageText('');
|
||||
onCreateFromForm?.(formPayload);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-6xl flex-col">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
disabled={isBusy}
|
||||
className="platform-button platform-button--ghost min-h-0 self-start px-3 py-1.5 text-[11px]"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col overflow-hidden">
|
||||
{showBackButton ? (
|
||||
<div className="mb-3 flex shrink-0 items-center justify-between gap-3 sm:mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
disabled={isBusy}
|
||||
className={`platform-button platform-button--ghost min-h-0 self-start px-3 py-1.5 text-[11px] ${isBusy ? 'opacity-45' : ''}`}
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,0.9fr)_minmax(20rem,0.55fr)]">
|
||||
<section className="platform-subpanel rounded-[1.45rem] p-4 sm:p-5">
|
||||
<div className="mb-4">
|
||||
<h1 className="m-0 text-3xl font-black leading-tight text-[var(--platform-text-strong)] sm:text-5xl">
|
||||
视觉小说
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden pr-0">
|
||||
{title ? (
|
||||
<div className="mb-3 shrink-0 sm:mb-5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="m-0 text-3xl font-black leading-none tracking-normal text-[var(--platform-text-strong)] sm:text-7xl">
|
||||
{title}
|
||||
</h1>
|
||||
<span className="rounded-full border border-rose-200 bg-rose-50 px-3 py-1 text-[11px] font-black text-rose-700">
|
||||
BETA
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
{SOURCE_OPTIONS.map((option) => (
|
||||
<VisualNovelSourceButton
|
||||
key={option.id}
|
||||
active={sourceMode === option.id}
|
||||
disabled={isBusy}
|
||||
icon={option.icon}
|
||||
label={option.label}
|
||||
onClick={() => setSourceMode(option.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{sourceMode === 'document' ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy || isDocumentUploading}
|
||||
onClick={() => documentFileInputRef.current?.click()}
|
||||
className="platform-button platform-button--secondary min-h-10 px-4 py-2 text-sm"
|
||||
>
|
||||
{isDocumentUploading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4" />
|
||||
)}
|
||||
{documentAssetId ? '重新上传文档' : '上传平台文档'}
|
||||
</button>
|
||||
{documentAssetId ? (
|
||||
<span className="rounded-full border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-2 text-xs font-semibold text-[var(--platform-text-base)]">
|
||||
{documentAssetLabel || '已绑定平台文档'}
|
||||
</span>
|
||||
) : null}
|
||||
<input
|
||||
ref={documentFileInputRef}
|
||||
type="file"
|
||||
accept=".txt,.md,.markdown,.docx,.csv,.json"
|
||||
disabled={isBusy || isDocumentUploading}
|
||||
onChange={(event) => {
|
||||
void handleDocumentFileChange(event);
|
||||
}}
|
||||
className="hidden"
|
||||
aria-label="上传文档"
|
||||
/>
|
||||
</div>
|
||||
{documentAssetId ? (
|
||||
<div className="truncate rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-4 py-2 text-xs font-semibold text-[var(--platform-text-soft)]">
|
||||
资产 ID:{documentAssetId}
|
||||
</div>
|
||||
) : null}
|
||||
{documentUploadError ? (
|
||||
<div className="rounded-[1rem] border border-rose-200/50 bg-rose-500/10 px-4 py-2 text-sm leading-6 text-rose-700">
|
||||
{documentUploadError}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<label className="mt-4 block">
|
||||
<span className="sr-only">创作想法</span>
|
||||
<section className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div
|
||||
className={`grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)_auto] gap-2 sm:gap-3 lg:grid-cols-[minmax(0,1.1fr)_minmax(16rem,0.9fr)] lg:grid-rows-1 ${isBusy ? 'opacity-55' : ''}`}
|
||||
>
|
||||
<label className="block min-h-0">
|
||||
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
|
||||
一句话创作
|
||||
</span>
|
||||
<textarea
|
||||
value={seedText}
|
||||
disabled={isBusy || sourceMode === 'blank'}
|
||||
rows={8}
|
||||
onChange={(event) => setSeedText(event.target.value)}
|
||||
placeholder={
|
||||
sourceMode === 'document'
|
||||
? '粘贴文档摘要或选择平台文档资产'
|
||||
: sourceMode === 'blank'
|
||||
? '空白创建将直接进入结果页'
|
||||
: '雪夜列车、旧电台、失踪乘客'
|
||||
value={formState.ideaText}
|
||||
disabled={isBusy}
|
||||
rows={5}
|
||||
placeholder=""
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
ideaText: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="w-full resize-none rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/88 px-4 py-4 text-base leading-7 text-[var(--platform-text-strong)] outline-none placeholder:text-zinc-400 disabled:opacity-60"
|
||||
aria-label="创作想法"
|
||||
className="h-full min-h-[7.75rem] w-full resize-none rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base leading-6 text-[var(--platform-text-strong)] outline-none sm:min-h-[9rem] lg:min-h-[14rem]"
|
||||
aria-label="一句话创作"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex min-h-0 flex-col gap-2 overflow-hidden">
|
||||
<div className="min-h-0">
|
||||
<div className="mb-1.5 text-sm font-black text-[var(--platform-text-strong)]">
|
||||
视觉画风
|
||||
</div>
|
||||
<div
|
||||
className="flex snap-x gap-2 overflow-x-auto overscroll-x-contain pb-1 scrollbar-hide touch-pan-x [-webkit-overflow-scrolling:touch]"
|
||||
aria-label="视觉画风"
|
||||
>
|
||||
{VISUAL_NOVEL_STYLE_OPTIONS.map((option) => (
|
||||
<VisualNovelStyleButton
|
||||
key={option.id}
|
||||
active={formState.visualStyleId === option.id}
|
||||
disabled={isBusy}
|
||||
label={option.label}
|
||||
onClick={() =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
visualStyleId: option.id,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 shrink-0 space-y-3">
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
|
||||
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canStart}
|
||||
onClick={startDraft}
|
||||
className="platform-button platform-button--primary min-h-11 px-5 py-3"
|
||||
>
|
||||
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
<Sparkles className="h-4 w-4" />
|
||||
{sourceMode === 'blank' ? '进入结果页' : '生成底稿'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="platform-subpanel flex min-h-[22rem] flex-col rounded-[1.45rem] p-4 sm:p-5">
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{progressItems.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className="min-w-0 rounded-[0.9rem] bg-white/72 px-2 py-2 text-center"
|
||||
>
|
||||
<div className="truncate text-[11px] font-bold text-[var(--platform-text-soft)]">
|
||||
{item.label}
|
||||
</div>
|
||||
<div className="mt-1 truncate text-sm font-black text-[var(--platform-text-strong)]">
|
||||
{item.value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 min-h-0 flex-1 space-y-3 overflow-y-auto pr-1">
|
||||
{displaySession.messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`max-w-[88%] rounded-[1.1rem] px-3 py-2 text-sm leading-6 ${
|
||||
message.role === 'user'
|
||||
? 'ml-auto bg-[var(--platform-button-primary-fill)] text-[var(--platform-button-primary-text)]'
|
||||
: 'bg-white/78 text-[var(--platform-text-strong)]'
|
||||
}`}
|
||||
>
|
||||
{message.text}
|
||||
</div>
|
||||
))}
|
||||
{streamingReplyText ? (
|
||||
<div className="max-w-[88%] rounded-[1.1rem] bg-white/78 px-3 py-2 text-sm leading-6 text-[var(--platform-text-strong)]">
|
||||
{streamingReplyText}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{pendingAction ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() =>
|
||||
onExecuteAction?.({
|
||||
actionId: pendingAction.actionId,
|
||||
kind: pendingAction.kind,
|
||||
targetId: pendingAction.targetId ?? null,
|
||||
payload: pendingAction.payload,
|
||||
})
|
||||
}
|
||||
className="platform-button platform-button--secondary mt-4 min-h-11 justify-center px-4 py-3"
|
||||
>
|
||||
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
<Sparkles className="h-4 w-4" />
|
||||
{pendingAction.label || '执行操作'}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 flex gap-2">
|
||||
<input
|
||||
value={messageText}
|
||||
disabled={isBusy}
|
||||
onChange={(event) => setMessageText(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
submitMessage();
|
||||
}
|
||||
}}
|
||||
className="min-h-11 min-w-0 flex-1 rounded-full border border-[var(--platform-subpanel-border)] bg-white/88 px-4 text-sm text-[var(--platform-text-strong)] outline-none"
|
||||
placeholder="补充设定"
|
||||
aria-label="补充设定"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canSend}
|
||||
onClick={submitMessage}
|
||||
className="platform-icon-button h-11 w-11"
|
||||
aria-label="发送"
|
||||
title="发送"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end pb-[max(0.25rem,env(safe-area-inset-bottom))]">
|
||||
<div className="mt-2 flex shrink-0 justify-center pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:mt-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy || !displaySession.draft}
|
||||
onClick={() => onOpenResult?.(displaySession)}
|
||||
className="platform-button platform-button--primary min-h-11 px-5 py-3"
|
||||
disabled={!canSubmit}
|
||||
onClick={startDraft}
|
||||
className={`platform-button platform-button--primary min-h-10 px-4 py-2 text-sm sm:min-h-11 sm:px-5 ${!canSubmit ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
>
|
||||
进入结果页
|
||||
<span className="inline-flex flex-wrap items-center justify-center gap-1.5 sm:gap-2">
|
||||
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
{session ? (
|
||||
<Sparkles className="h-4 w-4" />
|
||||
) : (
|
||||
<WandSparkles className="h-4 w-4" />
|
||||
)}
|
||||
<span>生成视觉小说草稿</span>
|
||||
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold">
|
||||
消耗20光点
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function buildVisualNovelEntryGenerationAnchorEntries(
|
||||
payload: VisualNovelEntryFormPayload | null | undefined,
|
||||
): CustomWorldStructuredAnchorEntry[] {
|
||||
if (!payload) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'visual-novel-idea',
|
||||
label: '一句话',
|
||||
value: payload.ideaText,
|
||||
},
|
||||
{
|
||||
id: 'visual-novel-style',
|
||||
label: '视觉画风',
|
||||
value: payload.visualStyleLabel,
|
||||
},
|
||||
].filter((entry) => entry.value.trim());
|
||||
}
|
||||
|
||||
export function buildVisualNovelEntryGenerationProgress(
|
||||
startedAtMs: number | null,
|
||||
phase: 'generating' | 'ready' | 'failed',
|
||||
nowMs = Date.now(),
|
||||
): CustomWorldGenerationProgress {
|
||||
const elapsedMs = startedAtMs ? Math.max(0, nowMs - startedAtMs) : 0;
|
||||
const timeline: [
|
||||
{
|
||||
id: string;
|
||||
label: string;
|
||||
detail: string;
|
||||
weight: number;
|
||||
durationMs: number;
|
||||
},
|
||||
{
|
||||
id: string;
|
||||
label: string;
|
||||
detail: string;
|
||||
weight: number;
|
||||
durationMs: number;
|
||||
},
|
||||
{
|
||||
id: string;
|
||||
label: string;
|
||||
detail: string;
|
||||
weight: number;
|
||||
durationMs: number;
|
||||
},
|
||||
] = [
|
||||
{
|
||||
id: 'visual-novel-session',
|
||||
label: '创建创作会话',
|
||||
detail: '写入一句话与视觉画风,准备生成视觉小说底稿。',
|
||||
weight: 24,
|
||||
durationMs: 5_000,
|
||||
},
|
||||
{
|
||||
id: 'visual-novel-draft',
|
||||
label: '生成故事底稿',
|
||||
detail: '整理世界观、角色、场景和剧情阶段。',
|
||||
weight: 56,
|
||||
durationMs: 22_000,
|
||||
},
|
||||
{
|
||||
id: 'visual-novel-ready',
|
||||
label: '准备草稿页',
|
||||
detail: '校验可编辑字段并进入草稿页。',
|
||||
weight: 20,
|
||||
durationMs: 4_000,
|
||||
},
|
||||
];
|
||||
let elapsedBeforeStep = 0;
|
||||
const activeStepIndex =
|
||||
phase === 'ready'
|
||||
? timeline.length - 1
|
||||
: timeline.findIndex((step) => {
|
||||
const elapsedInStep = elapsedMs - elapsedBeforeStep;
|
||||
const isActive = elapsedInStep < step.durationMs;
|
||||
if (!isActive) {
|
||||
elapsedBeforeStep += step.durationMs;
|
||||
}
|
||||
return isActive;
|
||||
});
|
||||
const normalizedActiveStepIndex =
|
||||
activeStepIndex >= 0 ? activeStepIndex : timeline.length - 1;
|
||||
const activeStep = timeline[normalizedActiveStepIndex] ?? timeline[0];
|
||||
const activeElapsed =
|
||||
elapsedMs -
|
||||
timeline
|
||||
.slice(0, normalizedActiveStepIndex)
|
||||
.reduce((sum, step) => sum + step.durationMs, 0);
|
||||
const activeRatio =
|
||||
phase === 'ready'
|
||||
? 1
|
||||
: phase === 'failed'
|
||||
? 0
|
||||
: Math.max(0, Math.min(1, activeElapsed / activeStep.durationMs));
|
||||
const completedWeight = timeline
|
||||
.slice(0, phase === 'ready' ? timeline.length : normalizedActiveStepIndex)
|
||||
.reduce((sum, step) => sum + step.weight, 0);
|
||||
const overallProgress =
|
||||
phase === 'ready'
|
||||
? 100
|
||||
: phase === 'failed'
|
||||
? Math.max(1, completedWeight)
|
||||
: Math.min(98, completedWeight + activeStep.weight * activeRatio);
|
||||
|
||||
return {
|
||||
phaseId: phase,
|
||||
phaseLabel:
|
||||
phase === 'ready'
|
||||
? '生成完成'
|
||||
: phase === 'failed'
|
||||
? '生成失败'
|
||||
: activeStep.label,
|
||||
phaseDetail:
|
||||
phase === 'ready'
|
||||
? '视觉小说草稿已准备完成。'
|
||||
: phase === 'failed'
|
||||
? '草稿生成失败,请返回入口页调整后重试。'
|
||||
: activeStep.detail,
|
||||
batchLabel: activeStep.label,
|
||||
overallProgress: Math.max(0, Math.min(100, Math.round(overallProgress))),
|
||||
completedWeight: Math.max(0, Math.min(100, Math.round(overallProgress))),
|
||||
totalWeight: 100,
|
||||
elapsedMs,
|
||||
estimatedRemainingMs:
|
||||
phase === 'ready' ? 0 : Math.max(0, 31_000 - elapsedMs),
|
||||
activeStepIndex: normalizedActiveStepIndex,
|
||||
steps: timeline.map((step, index) => {
|
||||
const isCompleted =
|
||||
phase === 'ready' || index < normalizedActiveStepIndex;
|
||||
const isActive =
|
||||
phase !== 'failed' &&
|
||||
!isCompleted &&
|
||||
index === normalizedActiveStepIndex;
|
||||
const status: 'completed' | 'active' | 'pending' = isCompleted
|
||||
? 'completed'
|
||||
: isActive
|
||||
? 'active'
|
||||
: 'pending';
|
||||
return {
|
||||
id: step.id,
|
||||
label: step.label,
|
||||
detail: step.detail,
|
||||
completed: isCompleted ? 1 : isActive ? activeRatio : 0,
|
||||
total: 1,
|
||||
status,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export default VisualNovelAgentWorkspace;
|
||||
|
||||
Reference in New Issue
Block a user