1
This commit is contained in:
@@ -108,11 +108,10 @@ test('creation hub reflects updated draft title summary and counts after rerende
|
||||
const puzzleButton = screen.getByRole('button', { name: /拼图.*创意礼物/u });
|
||||
const match3dButton = screen.getByRole('button', { name: /抓大鹅/u });
|
||||
expect(
|
||||
puzzleButton.compareDocumentPosition(rpgButton) &
|
||||
rpgButton.compareDocumentPosition(puzzleButton) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
expect((rpgButton as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(within(rpgButton).getAllByText('敬请期待').length).toBeGreaterThan(0);
|
||||
expect((rpgButton as HTMLButtonElement).disabled).toBe(false);
|
||||
expect((match3dButton as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(
|
||||
within(match3dButton).getAllByText('敬请期待').length,
|
||||
|
||||
@@ -43,7 +43,6 @@ test('creation hub draft card renders compiled work summary fields', () => {
|
||||
expect(html).toContain('玩家是失职返乡的守灯人');
|
||||
expect(html).toContain('守灯会与沉船商盟争夺航道解释权');
|
||||
expect(html).toContain('角色扮演');
|
||||
expect(html).toContain('敬请期待');
|
||||
expect(html).toContain('拼图');
|
||||
expect(html).toContain('创意礼物,生活分享');
|
||||
expect(html).not.toContain('大鱼吃小鱼');
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
|
||||
import { NEW_WORK_ENTRY_CONFIG } from '../../config/newWorkEntryConfig';
|
||||
import {
|
||||
getVisiblePlatformCreationTypes,
|
||||
type PlatformCreationTypeId,
|
||||
@@ -27,13 +28,15 @@ export function CustomWorldCreationStartCard({
|
||||
<div className="relative z-10 space-y-2.5 sm:space-y-4 xl:space-y-3">
|
||||
<div className="flex items-center justify-between gap-3 xl:items-end">
|
||||
<div className="text-xl font-black leading-none text-white sm:text-3xl xl:text-2xl">
|
||||
新建作品
|
||||
{NEW_WORK_ENTRY_CONFIG.startCard.title}
|
||||
</div>
|
||||
<div className="hidden text-sm leading-6 text-zinc-200/88 sm:block xl:text-xs xl:leading-5">
|
||||
直接选择游戏创作模板,立刻进入对应的共创工作台。
|
||||
{NEW_WORK_ENTRY_CONFIG.startCard.description}
|
||||
</div>
|
||||
<span className="platform-pill platform-pill--neutral shrink-0 border-white/25 bg-white/14 px-2.5 text-xs text-white sm:hidden">
|
||||
{busy ? '正在开启' : '选择模板'}
|
||||
{busy
|
||||
? NEW_WORK_ENTRY_CONFIG.startCard.busyBadge
|
||||
: NEW_WORK_ENTRY_CONFIG.startCard.idleBadge}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
|
||||
import { NEW_WORK_ENTRY_CONFIG } from '../../config/newWorkEntryConfig';
|
||||
import { UnifiedModal } from '../common/UnifiedModal';
|
||||
import { getVisiblePlatformCreationTypes } from './platformEntryCreationTypes';
|
||||
|
||||
@@ -86,8 +87,8 @@ export function PlatformEntryCreationTypeModal({
|
||||
return (
|
||||
<UnifiedModal
|
||||
open={isOpen}
|
||||
title="选择创作类型"
|
||||
description="先选玩法类型,再进入对应创作工作台。"
|
||||
title={NEW_WORK_ENTRY_CONFIG.typeModal.title}
|
||||
description={NEW_WORK_ENTRY_CONFIG.typeModal.description}
|
||||
onClose={onClose}
|
||||
closeDisabled={isBusy}
|
||||
size="lg"
|
||||
|
||||
@@ -655,6 +655,7 @@ function buildPuzzleCompileActionFromFormPayload(
|
||||
...(workDescription ? { workDescription } : {}),
|
||||
...(pictureDescription ? { pictureDescription } : {}),
|
||||
referenceImageSrc: payload?.referenceImageSrc || null,
|
||||
imageModel: payload?.imageModel ?? null,
|
||||
candidateCount: 1,
|
||||
};
|
||||
}
|
||||
@@ -687,6 +688,7 @@ function buildPuzzleFormPayloadFromSession(
|
||||
workDescription,
|
||||
pictureDescription,
|
||||
referenceImageSrc: null,
|
||||
imageModel: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -714,6 +716,10 @@ function buildPuzzleFormPayloadFromAction(
|
||||
payload.action === 'compile_puzzle_draft'
|
||||
? (payload.referenceImageSrc ?? null)
|
||||
: null,
|
||||
imageModel:
|
||||
payload.action === 'compile_puzzle_draft'
|
||||
? (payload.imageModel ?? null)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -941,8 +947,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
const [match3dProfile, setMatch3DProfile] =
|
||||
useState<Match3DWorkProfile | null>(null);
|
||||
const [match3dRun, setMatch3DRun] = useState<Match3DRunSnapshot | null>(null);
|
||||
const [match3dRuntimeReturnStage, setMatch3DRuntimeReturnStage] =
|
||||
useState<'match3d-result' | 'work-detail'>('match3d-result');
|
||||
const [match3dRuntimeReturnStage, setMatch3DRuntimeReturnStage] = useState<
|
||||
'match3d-result' | 'work-detail'
|
||||
>('match3d-result');
|
||||
const [isMatch3DLoadingLibrary, setIsMatch3DLoadingLibrary] = useState(false);
|
||||
const [bigFishRun, setBigFishRun] =
|
||||
useState<BigFishRuntimeSnapshotResponse | null>(null);
|
||||
@@ -993,8 +1000,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
const [deletingCreationWorkId, setDeletingCreationWorkId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [claimingPuzzlePointIncentiveProfileId, setClaimingPuzzlePointIncentiveProfileId] =
|
||||
useState<string | null>(null);
|
||||
const [
|
||||
claimingPuzzlePointIncentiveProfileId,
|
||||
setClaimingPuzzlePointIncentiveProfileId,
|
||||
] = useState<string | null>(null);
|
||||
const isBigFishCreationVisible = isPlatformCreationTypeVisible('big-fish');
|
||||
const [profilePlayStats, setProfilePlayStats] =
|
||||
useState<ProfilePlayStatsResponse | null>(null);
|
||||
@@ -1099,7 +1108,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
return galleryResponse.items;
|
||||
} catch (error) {
|
||||
setMatch3DGalleryEntries([]);
|
||||
setMatch3DError(resolveMatch3DErrorMessage(error, '读取抓大鹅广场失败。'));
|
||||
setMatch3DError(
|
||||
resolveMatch3DErrorMessage(error, '读取抓大鹅广场失败。'),
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}, [resolveMatch3DErrorMessage]);
|
||||
@@ -1293,7 +1304,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
);
|
||||
return mergePlatformPublicGalleryEntries(
|
||||
platformBootstrap.publishedGalleryEntries,
|
||||
[...bigFishPublicEntries, ...match3dPublicEntries, ...puzzlePublicEntries],
|
||||
[
|
||||
...bigFishPublicEntries,
|
||||
...match3dPublicEntries,
|
||||
...puzzlePublicEntries,
|
||||
],
|
||||
).slice(0, 6);
|
||||
}, [
|
||||
isBigFishCreationVisible,
|
||||
@@ -1665,24 +1680,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 openPuzzleAgentWorkspace = useCallback(async () => {
|
||||
setPuzzleRun(null);
|
||||
setPuzzleOperation(null);
|
||||
@@ -1729,6 +1726,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
workTitle: payload.workTitle ?? payload.seedText ?? '',
|
||||
workDescription: payload.workDescription ?? '',
|
||||
pictureDescription: payload.pictureDescription ?? '',
|
||||
imageModel: payload.imageModel ?? null,
|
||||
});
|
||||
setPuzzleOperation(response.operation);
|
||||
puzzleFlow.setSession(response.session);
|
||||
@@ -1826,12 +1824,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
const handleCreationHubCreateType = useCallback(
|
||||
(type: PlatformCreationTypeId) => {
|
||||
if (
|
||||
type === 'rpg' ||
|
||||
type === 'match3d' ||
|
||||
type === 'airp' ||
|
||||
type === 'visual-novel'
|
||||
) {
|
||||
if (type === 'match3d' || type === 'airp' || type === 'visual-novel') {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1839,6 +1832,13 @@ export function PlatformEntryFlowShellImpl({
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'rpg') {
|
||||
runProtectedAction(() => {
|
||||
void sessionController.openRpgAgentWorkspace();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'big-fish') {
|
||||
runProtectedAction(() => {
|
||||
void openBigFishAgentWorkspace();
|
||||
@@ -1857,6 +1857,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
openPuzzleAgentWorkspace,
|
||||
prepareCreationLaunch,
|
||||
runProtectedAction,
|
||||
sessionController,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -2332,8 +2333,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
propKind === 'extendTime'
|
||||
? extendLocalPuzzleTime(currentRun)
|
||||
: propKind === 'freezeTime'
|
||||
? applyLocalPuzzleFreezeTime(currentRun)
|
||||
: setLocalPuzzlePaused(currentRun, propKind === 'reference');
|
||||
? applyLocalPuzzleFreezeTime(currentRun)
|
||||
: setLocalPuzzlePaused(currentRun, propKind === 'reference');
|
||||
puzzleRunRef.current = nextRun;
|
||||
setPuzzleRun(nextRun);
|
||||
return nextRun;
|
||||
@@ -2367,7 +2368,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
selectedPuzzleDetail,
|
||||
);
|
||||
if (isLocalPuzzleRun(puzzleRun)) {
|
||||
const nextRun = restartLocalPuzzleLevel(puzzleRunRef.current ?? puzzleRun);
|
||||
const nextRun = restartLocalPuzzleLevel(
|
||||
puzzleRunRef.current ?? puzzleRun,
|
||||
);
|
||||
puzzleRunRef.current = nextRun;
|
||||
setPuzzleRun(nextRun);
|
||||
return;
|
||||
@@ -2531,68 +2534,68 @@ export function PlatformEntryFlowShellImpl({
|
||||
setPuzzleError,
|
||||
]);
|
||||
|
||||
const advancePuzzleLevel = useCallback(async (target?: {
|
||||
profileId?: string;
|
||||
levelId?: string | null;
|
||||
}) => {
|
||||
if (!puzzleRun || isPuzzleBusy || isPuzzleLeaderboardBusy) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentLevel = puzzleRun.currentLevel;
|
||||
if (!currentLevel || currentLevel.status !== 'cleared') {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPuzzleBusy(true);
|
||||
setIsPuzzleNextLevelGenerating(true);
|
||||
setPuzzleError(null);
|
||||
|
||||
try {
|
||||
const targetProfileId = target?.profileId?.trim();
|
||||
if (
|
||||
targetProfileId &&
|
||||
targetProfileId !== currentLevel.profileId &&
|
||||
puzzleRun.nextLevelMode === 'similarWorks'
|
||||
) {
|
||||
await startPuzzleRunFromProfile(
|
||||
targetProfileId,
|
||||
'puzzle-gallery-detail',
|
||||
undefined,
|
||||
false,
|
||||
null,
|
||||
);
|
||||
const advancePuzzleLevel = useCallback(
|
||||
async (target?: { profileId?: string; levelId?: string | null }) => {
|
||||
if (!puzzleRun || isPuzzleBusy || isPuzzleLeaderboardBusy) {
|
||||
return;
|
||||
}
|
||||
const { run } = isLocalPuzzleRun(puzzleRun)
|
||||
? await advanceLocalPuzzleNextLevel({
|
||||
run: puzzleRun,
|
||||
sourceSessionId:
|
||||
selectedPuzzleDetail?.sourceSessionId ??
|
||||
puzzleSession?.sessionId ??
|
||||
null,
|
||||
})
|
||||
: await advancePuzzleNextLevel(puzzleRun.runId);
|
||||
setPuzzleRun(run);
|
||||
if (!isLocalPuzzleRun(puzzleRun)) {
|
||||
void platformBootstrap.refreshSaveArchives();
|
||||
|
||||
const currentLevel = puzzleRun.currentLevel;
|
||||
if (!currentLevel || currentLevel.status !== 'cleared') {
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
setPuzzleError(resolvePuzzleErrorMessage(error, '准备下一关失败。'));
|
||||
} finally {
|
||||
setIsPuzzleNextLevelGenerating(false);
|
||||
setIsPuzzleBusy(false);
|
||||
}
|
||||
}, [
|
||||
isPuzzleBusy,
|
||||
isPuzzleLeaderboardBusy,
|
||||
platformBootstrap,
|
||||
puzzleRun,
|
||||
puzzleSession,
|
||||
resolvePuzzleErrorMessage,
|
||||
selectedPuzzleDetail,
|
||||
startPuzzleRunFromProfile,
|
||||
]);
|
||||
|
||||
setIsPuzzleBusy(true);
|
||||
setIsPuzzleNextLevelGenerating(true);
|
||||
setPuzzleError(null);
|
||||
|
||||
try {
|
||||
const targetProfileId = target?.profileId?.trim();
|
||||
if (
|
||||
targetProfileId &&
|
||||
targetProfileId !== currentLevel.profileId &&
|
||||
puzzleRun.nextLevelMode === 'similarWorks'
|
||||
) {
|
||||
await startPuzzleRunFromProfile(
|
||||
targetProfileId,
|
||||
'puzzle-gallery-detail',
|
||||
undefined,
|
||||
false,
|
||||
null,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const { run } = isLocalPuzzleRun(puzzleRun)
|
||||
? await advanceLocalPuzzleNextLevel({
|
||||
run: puzzleRun,
|
||||
sourceSessionId:
|
||||
selectedPuzzleDetail?.sourceSessionId ??
|
||||
puzzleSession?.sessionId ??
|
||||
null,
|
||||
})
|
||||
: await advancePuzzleNextLevel(puzzleRun.runId);
|
||||
setPuzzleRun(run);
|
||||
if (!isLocalPuzzleRun(puzzleRun)) {
|
||||
void platformBootstrap.refreshSaveArchives();
|
||||
}
|
||||
} catch (error) {
|
||||
setPuzzleError(resolvePuzzleErrorMessage(error, '准备下一关失败。'));
|
||||
} finally {
|
||||
setIsPuzzleNextLevelGenerating(false);
|
||||
setIsPuzzleBusy(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
isPuzzleBusy,
|
||||
isPuzzleLeaderboardBusy,
|
||||
platformBootstrap,
|
||||
puzzleRun,
|
||||
puzzleSession,
|
||||
resolvePuzzleErrorMessage,
|
||||
selectedPuzzleDetail,
|
||||
startPuzzleRunFromProfile,
|
||||
],
|
||||
);
|
||||
|
||||
const leaveAgentWorkspace = useCallback(() => {
|
||||
enterCreateTab();
|
||||
@@ -2935,14 +2938,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
.then((response) => {
|
||||
const updatedWork = response.item;
|
||||
setPuzzleWorks((current) =>
|
||||
current.map((item) =>
|
||||
mergePuzzleWorkSummary(item, updatedWork),
|
||||
),
|
||||
current.map((item) => mergePuzzleWorkSummary(item, updatedWork)),
|
||||
);
|
||||
setPuzzleGalleryEntries((current) =>
|
||||
current.map((item) =>
|
||||
mergePuzzleWorkSummary(item, updatedWork),
|
||||
),
|
||||
current.map((item) => mergePuzzleWorkSummary(item, updatedWork)),
|
||||
);
|
||||
setSelectedPuzzleDetail((current) =>
|
||||
current ? mergePuzzleWorkSummary(current, updatedWork) : current,
|
||||
@@ -3316,7 +3315,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
return;
|
||||
}
|
||||
|
||||
const restoredSession = await match3dFlow.restoreDraft(item.sourceSessionId);
|
||||
const restoredSession = await match3dFlow.restoreDraft(
|
||||
item.sourceSessionId,
|
||||
);
|
||||
if (!restoredSession) {
|
||||
await refreshMatch3DShelf().catch(() => undefined);
|
||||
return;
|
||||
@@ -3395,7 +3396,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
if (isPuzzleGalleryEntry(selectedPublicWorkDetail)) {
|
||||
const work = mapPublicWorkDetailToPuzzleWork(selectedPublicWorkDetail);
|
||||
if (!work) {
|
||||
setPublicWorkDetailError('当前拼图作品信息不完整,暂时无法进入玩法。');
|
||||
setPublicWorkDetailError(
|
||||
'当前拼图作品信息不完整,暂时无法进入玩法。',
|
||||
);
|
||||
return;
|
||||
}
|
||||
setPublicWorkDetailError(null);
|
||||
@@ -3411,7 +3414,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
if (isMatch3DGalleryEntry(selectedPublicWorkDetail)) {
|
||||
const work = mapPublicWorkDetailToMatch3DWork(selectedPublicWorkDetail);
|
||||
if (!work) {
|
||||
setPublicWorkDetailError('当前抓大鹅作品信息不完整,暂时无法进入玩法。');
|
||||
setPublicWorkDetailError(
|
||||
'当前抓大鹅作品信息不完整,暂时无法进入玩法。',
|
||||
);
|
||||
return;
|
||||
}
|
||||
setPublicWorkDetailError(null);
|
||||
@@ -4487,7 +4492,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
>
|
||||
<Suspense
|
||||
fallback={<LazyPanelFallback label="正在加载抓大鹅共创工作区..." />}
|
||||
fallback={
|
||||
<LazyPanelFallback label="正在加载抓大鹅共创工作区..." />
|
||||
}
|
||||
>
|
||||
<Match3DAgentWorkspace
|
||||
session={match3dSession}
|
||||
@@ -4520,7 +4527,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
>
|
||||
<Match3DResultView
|
||||
profile={
|
||||
match3dProfile ?? buildMatch3DProfileFromSession(match3dSession)!
|
||||
match3dProfile ??
|
||||
buildMatch3DProfileFromSession(match3dSession)!
|
||||
}
|
||||
draft={match3dSession.draft}
|
||||
isBusy={isMatch3DBusy}
|
||||
@@ -4537,7 +4545,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
refreshMatch3DShelf(),
|
||||
refreshMatch3DGallery(),
|
||||
]);
|
||||
openPublicWorkDetail(mapMatch3DWorkToPublicWorkDetail(profile));
|
||||
openPublicWorkDetail(
|
||||
mapMatch3DWorkToPublicWorkDetail(profile),
|
||||
);
|
||||
}}
|
||||
onStartTestRun={(profile) => {
|
||||
setMatch3DProfile(profile);
|
||||
@@ -4565,7 +4575,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
error={match3dError}
|
||||
onBack={() => {
|
||||
if (match3dRun?.runId && match3dRun.status === 'running') {
|
||||
void stopMatch3DRun(match3dRun.runId).catch(() => undefined);
|
||||
void stopMatch3DRun(match3dRun.runId).catch(
|
||||
() => undefined,
|
||||
);
|
||||
}
|
||||
setSelectionStage(match3dRuntimeReturnStage);
|
||||
}}
|
||||
@@ -4596,7 +4608,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
onClickItem={(payload) => {
|
||||
const runId = payload.runId ?? match3dRun?.runId;
|
||||
if (!runId) {
|
||||
return Promise.reject(new Error('抓大鹅运行态缺少 runId。'));
|
||||
return Promise.reject(
|
||||
new Error('抓大鹅运行态缺少 runId。'),
|
||||
);
|
||||
}
|
||||
return clickMatch3DItem(runId, payload);
|
||||
}}
|
||||
@@ -5084,7 +5098,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
setShowCreationTypeModal(false);
|
||||
}}
|
||||
onSelectRpg={() => {
|
||||
// RPG 创作入口当前为敬请期待;保留回调防御,避免旧入口绕过锁定态。
|
||||
runProtectedAction(() => {
|
||||
void sessionController.openRpgAgentWorkspace();
|
||||
});
|
||||
}}
|
||||
onSelectBigFish={() => {
|
||||
runProtectedAction(() => {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import { NEW_WORK_ENTRY_CONFIG } from '../../config/newWorkEntryConfig';
|
||||
import {
|
||||
getVisiblePlatformCreationTypes,
|
||||
isPlatformCreationTypeVisible,
|
||||
PLATFORM_CREATION_TYPES,
|
||||
} from './platformEntryCreationTypes';
|
||||
|
||||
test('platform creation types are derived from new work entry config', () => {
|
||||
const puzzleConfig = NEW_WORK_ENTRY_CONFIG.creationTypes.find(
|
||||
(item) => item.id === 'puzzle',
|
||||
);
|
||||
|
||||
expect(puzzleConfig).toBeTruthy();
|
||||
expect(PLATFORM_CREATION_TYPES).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: 'puzzle',
|
||||
title: puzzleConfig?.title,
|
||||
subtitle: puzzleConfig?.subtitle,
|
||||
badge: puzzleConfig?.badge,
|
||||
locked: false,
|
||||
hidden: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('new work entry config controls visibility and open order', () => {
|
||||
const visibleIds = getVisiblePlatformCreationTypes().map((item) => item.id);
|
||||
|
||||
expect(isPlatformCreationTypeVisible('big-fish')).toBe(false);
|
||||
expect(visibleIds).not.toContain('big-fish');
|
||||
expect(visibleIds[0]).toBe('rpg');
|
||||
expect(visibleIds).toEqual([
|
||||
'rpg',
|
||||
'puzzle',
|
||||
'match3d',
|
||||
'airp',
|
||||
'visual-novel',
|
||||
]);
|
||||
});
|
||||
@@ -1,10 +1,9 @@
|
||||
export type PlatformCreationTypeId =
|
||||
| 'rpg'
|
||||
| 'big-fish'
|
||||
| 'match3d'
|
||||
| 'puzzle'
|
||||
| 'airp'
|
||||
| 'visual-novel';
|
||||
import {
|
||||
NEW_WORK_ENTRY_CONFIG,
|
||||
type NewWorkEntryCreationTypeId,
|
||||
} from '../../config/newWorkEntryConfig';
|
||||
|
||||
export type PlatformCreationTypeId = NewWorkEntryCreationTypeId;
|
||||
|
||||
export type PlatformCreationTypeCard = {
|
||||
id: PlatformCreationTypeId;
|
||||
@@ -39,51 +38,15 @@ export function isPlatformCreationTypeVisible(id: PlatformCreationTypeId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 创作页与类型弹层共用同一份模板元数据,避免多入口文案和可用状态漂移。
|
||||
* 创作页与类型弹层共用同一份新建作品入口配置,避免多入口文案和开放状态漂移。
|
||||
* `hidden` 只控制平台入口是否展示,不影响既有玩法链路和路由能力。
|
||||
*/
|
||||
export const PLATFORM_CREATION_TYPES: PlatformCreationTypeCard[] = [
|
||||
{
|
||||
id: 'rpg',
|
||||
title: '角色扮演',
|
||||
subtitle: '敬请期待',
|
||||
badge: '敬请期待',
|
||||
locked: true,
|
||||
},
|
||||
{
|
||||
id: 'big-fish',
|
||||
title: '大鱼吃小鱼',
|
||||
subtitle: '实时成长玩法',
|
||||
badge: '可创建',
|
||||
locked: false,
|
||||
hidden: true,
|
||||
},
|
||||
{
|
||||
id: 'puzzle',
|
||||
title: '拼图',
|
||||
subtitle: '创意礼物,生活分享',
|
||||
badge: '可创建',
|
||||
locked: false,
|
||||
},
|
||||
{
|
||||
id: 'match3d',
|
||||
title: '抓大鹅',
|
||||
subtitle: '敬请期待',
|
||||
badge: '敬请期待',
|
||||
locked: true,
|
||||
},
|
||||
{
|
||||
id: 'airp',
|
||||
title: 'AIRP',
|
||||
subtitle: '敬请期待',
|
||||
badge: '敬请期待',
|
||||
locked: true,
|
||||
},
|
||||
{
|
||||
id: 'visual-novel',
|
||||
title: '视觉小说',
|
||||
subtitle: '敬请期待',
|
||||
badge: '敬请期待',
|
||||
locked: true,
|
||||
},
|
||||
];
|
||||
export const PLATFORM_CREATION_TYPES: PlatformCreationTypeCard[] =
|
||||
NEW_WORK_ENTRY_CONFIG.creationTypes.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
subtitle: item.subtitle,
|
||||
badge: item.badge,
|
||||
locked: !item.open,
|
||||
hidden: !item.visible,
|
||||
}));
|
||||
|
||||
@@ -100,6 +100,7 @@ test('puzzle workspace submits the work form instead of agent chat', () => {
|
||||
workDescription: '一套雨夜猫街主题拼图。',
|
||||
pictureDescription: '一只猫在雨夜灯牌下回头。',
|
||||
referenceImageSrc: null,
|
||||
imageModel: 'original',
|
||||
});
|
||||
expect(screen.queryByRole('button', { name: '补充剩余设定' })).toBeNull();
|
||||
expect(screen.queryByText('旧会话消息不再渲染为聊天入口。')).toBeNull();
|
||||
@@ -129,10 +130,44 @@ test('puzzle workspace falls back to compile action for restored sessions', () =
|
||||
workDescription: '雾港遗迹拼图',
|
||||
pictureDescription: '潮雾中的灯塔与断桥',
|
||||
referenceImageSrc: null,
|
||||
imageModel: 'original',
|
||||
candidateCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test('puzzle workspace switches the image model from the description box', () => {
|
||||
const onCreateFromForm = vi.fn();
|
||||
|
||||
render(
|
||||
<PuzzleAgentWorkspace
|
||||
session={null}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
onCreateFromForm={onCreateFromForm}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('作品名称'), {
|
||||
target: { value: '暖灯猫街' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('作品描述'), {
|
||||
target: { value: '一套雨夜猫街主题拼图。' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('画面描述'), {
|
||||
target: { value: '一只猫在雨夜灯牌下回头。' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '图片模型' }));
|
||||
fireEvent.click(screen.getByRole('menuitemradio', { name: 'nanobanana2' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成草稿/u }));
|
||||
|
||||
expect(onCreateFromForm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
imageModel: 'gemini-3.1-flash-image-preview',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('puzzle workspace restores form draft fields and autosaves edits', () => {
|
||||
vi.useFakeTimers();
|
||||
const onAutoSaveForm = vi.fn();
|
||||
@@ -208,5 +243,6 @@ test('puzzle workspace restores form draft fields and autosaves edits', () => {
|
||||
workDescription: '旧街雨夜的拼图草稿。',
|
||||
pictureDescription: '旧街灯牌下的猫和发光雨伞。',
|
||||
referenceImageSrc: null,
|
||||
imageModel: 'original',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,12 @@ import type {
|
||||
SendPuzzleAgentMessageRequest,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
|
||||
import {
|
||||
normalizePuzzleImageModel,
|
||||
PUZZLE_IMAGE_MODEL_ORIGINAL,
|
||||
type PuzzleImageModelId,
|
||||
} from './puzzleImageModelOptions';
|
||||
import { PuzzleImageModelPicker } from './PuzzleImageModelPicker';
|
||||
|
||||
type PuzzleAgentWorkspaceProps = {
|
||||
session: PuzzleAgentSessionSnapshot | null;
|
||||
@@ -27,6 +33,7 @@ type PuzzleFormState = {
|
||||
pictureDescription: string;
|
||||
referenceImageSrc: string;
|
||||
referenceImageLabel: string;
|
||||
imageModel: PuzzleImageModelId;
|
||||
};
|
||||
|
||||
const EMPTY_FORM_STATE: PuzzleFormState = {
|
||||
@@ -35,6 +42,7 @@ const EMPTY_FORM_STATE: PuzzleFormState = {
|
||||
pictureDescription: '',
|
||||
referenceImageSrc: '',
|
||||
referenceImageLabel: '',
|
||||
imageModel: PUZZLE_IMAGE_MODEL_ORIGINAL,
|
||||
};
|
||||
|
||||
function resolveInitialFormState(
|
||||
@@ -51,6 +59,7 @@ function resolveInitialFormState(
|
||||
referenceImageLabel: initialFormPayload?.referenceImageSrc
|
||||
? '已选择参考图'
|
||||
: '',
|
||||
imageModel: normalizePuzzleImageModel(initialFormPayload?.imageModel),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -64,6 +73,7 @@ function resolveInitialFormState(
|
||||
referenceImageLabel: initialFormPayload.referenceImageSrc
|
||||
? '已选择参考图'
|
||||
: '',
|
||||
imageModel: normalizePuzzleImageModel(initialFormPayload.imageModel),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -87,6 +97,7 @@ function resolveInitialFormState(
|
||||
session.draft?.summary || session.anchorPack.visualSubject.value || '',
|
||||
referenceImageSrc: '',
|
||||
referenceImageLabel: '',
|
||||
imageModel: PUZZLE_IMAGE_MODEL_ORIGINAL,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -151,9 +162,11 @@ export function PuzzleAgentWorkspace({
|
||||
workDescription,
|
||||
pictureDescription,
|
||||
referenceImageSrc: formState.referenceImageSrc || null,
|
||||
imageModel: formState.imageModel,
|
||||
}),
|
||||
[
|
||||
formState.referenceImageSrc,
|
||||
formState.imageModel,
|
||||
pictureDescription,
|
||||
workDescription,
|
||||
workTitle,
|
||||
@@ -163,6 +176,7 @@ export function PuzzleAgentWorkspace({
|
||||
autosavePayload.workTitle,
|
||||
autosavePayload.workDescription,
|
||||
autosavePayload.pictureDescription,
|
||||
autosavePayload.imageModel,
|
||||
]);
|
||||
const lastAutosaveSignatureRef = useRef(autosaveSignature);
|
||||
const autosaveSessionIdRef = useRef(session?.sessionId ?? null);
|
||||
@@ -240,6 +254,7 @@ export function PuzzleAgentWorkspace({
|
||||
workDescription,
|
||||
pictureDescription,
|
||||
referenceImageSrc: formState.referenceImageSrc || null,
|
||||
imageModel: formState.imageModel,
|
||||
};
|
||||
|
||||
if (!session && onCreateFromForm) {
|
||||
@@ -254,6 +269,7 @@ export function PuzzleAgentWorkspace({
|
||||
workDescription,
|
||||
pictureDescription,
|
||||
referenceImageSrc: formState.referenceImageSrc || null,
|
||||
imageModel: formState.imageModel,
|
||||
candidateCount: 1,
|
||||
});
|
||||
};
|
||||
@@ -332,6 +348,16 @@ export function PuzzleAgentWorkspace({
|
||||
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={formState.imageModel}
|
||||
disabled={isBusy}
|
||||
onChange={(imageModel) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
imageModel,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<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={
|
||||
|
||||
84
src/components/puzzle-agent/PuzzleImageModelPicker.tsx
Normal file
84
src/components/puzzle-agent/PuzzleImageModelPicker.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
getPuzzleImageModelLabel,
|
||||
normalizePuzzleImageModel,
|
||||
PUZZLE_IMAGE_MODEL_OPTIONS,
|
||||
type PuzzleImageModelId,
|
||||
} from './puzzleImageModelOptions';
|
||||
|
||||
type PuzzleImageModelPickerProps = {
|
||||
value: PuzzleImageModelId;
|
||||
disabled?: boolean;
|
||||
onChange: (value: PuzzleImageModelId) => void;
|
||||
};
|
||||
|
||||
export function PuzzleImageModelPicker({
|
||||
value,
|
||||
disabled = false,
|
||||
onChange,
|
||||
}: PuzzleImageModelPickerProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
const normalizedValue = normalizePuzzleImageModel(value);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (!rootRef.current?.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener('pointerdown', handlePointerDown);
|
||||
return () => window.removeEventListener('pointerdown', handlePointerDown);
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="absolute bottom-3 left-3 z-10">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setIsOpen((current) => !current)}
|
||||
className={`inline-flex min-h-8 max-w-[10rem] items-center rounded-full border border-[var(--platform-subpanel-border)] bg-white/96 px-3 text-[11px] font-bold text-[var(--platform-text-strong)] shadow-sm transition hover:bg-[var(--platform-subpanel-fill)] ${disabled ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isOpen}
|
||||
aria-label="图片模型"
|
||||
title="图片模型"
|
||||
>
|
||||
<span className="truncate">
|
||||
{getPuzzleImageModelLabel(normalizedValue)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isOpen ? (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute bottom-10 left-0 min-w-[11rem] overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/98 p-1 shadow-[0_16px_40px_rgba(0,0,0,0.18)]"
|
||||
>
|
||||
{PUZZLE_IMAGE_MODEL_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={option.id === normalizedValue}
|
||||
onClick={() => {
|
||||
onChange(option.id);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={`block min-h-9 w-full rounded-[0.8rem] px-3 text-left text-xs font-bold transition ${
|
||||
option.id === normalizedValue
|
||||
? 'bg-amber-100/80 text-amber-800'
|
||||
: 'text-[var(--platform-text-base)] hover:bg-[var(--platform-subpanel-fill)]'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
src/components/puzzle-agent/puzzleImageModelOptions.ts
Normal file
33
src/components/puzzle-agent/puzzleImageModelOptions.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export const PUZZLE_IMAGE_MODEL_ORIGINAL = 'original';
|
||||
export const PUZZLE_IMAGE_MODEL_GPT_IMAGE_2 = 'gpt-image-2';
|
||||
export const PUZZLE_IMAGE_MODEL_NANOBANANA2 = 'gemini-3.1-flash-image-preview';
|
||||
|
||||
export type PuzzleImageModelId =
|
||||
| typeof PUZZLE_IMAGE_MODEL_ORIGINAL
|
||||
| typeof PUZZLE_IMAGE_MODEL_GPT_IMAGE_2
|
||||
| typeof PUZZLE_IMAGE_MODEL_NANOBANANA2;
|
||||
|
||||
export const PUZZLE_IMAGE_MODEL_OPTIONS: Array<{
|
||||
id: PuzzleImageModelId;
|
||||
label: string;
|
||||
}> = [
|
||||
{ id: PUZZLE_IMAGE_MODEL_ORIGINAL, label: '原模型' },
|
||||
{ id: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2, label: 'gpt-image-2' },
|
||||
{ id: PUZZLE_IMAGE_MODEL_NANOBANANA2, label: 'nanobanana2' },
|
||||
];
|
||||
|
||||
export function normalizePuzzleImageModel(
|
||||
value: string | null | undefined,
|
||||
): PuzzleImageModelId {
|
||||
return (
|
||||
PUZZLE_IMAGE_MODEL_OPTIONS.find((option) => option.id === value)?.id ??
|
||||
PUZZLE_IMAGE_MODEL_ORIGINAL
|
||||
);
|
||||
}
|
||||
|
||||
export function getPuzzleImageModelLabel(model: PuzzleImageModelId) {
|
||||
return (
|
||||
PUZZLE_IMAGE_MODEL_OPTIONS.find((option) => option.id === model)?.label ??
|
||||
'原模型'
|
||||
);
|
||||
}
|
||||
@@ -232,13 +232,16 @@ describe('PuzzleResultView', () => {
|
||||
fireEvent.change(within(dialog).getByLabelText('画面描述'), {
|
||||
target: { value: '一只猫在雨夜灯牌下回头。' },
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /重新生成画面/u }));
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: /重新生成画面/u }),
|
||||
);
|
||||
|
||||
expect(onExecuteAction).toHaveBeenCalledWith({
|
||||
action: 'generate_puzzle_images',
|
||||
levelId: 'puzzle-level-1',
|
||||
promptText: '一只猫在雨夜灯牌下回头。',
|
||||
referenceImageSrc: undefined,
|
||||
imageModel: 'original',
|
||||
candidateCount: 1,
|
||||
levelsJson: expect.any(String),
|
||||
});
|
||||
@@ -295,9 +298,13 @@ describe('PuzzleResultView', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /新增关卡/u }));
|
||||
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
|
||||
expect(within(dialog).getByRole('button', { name: /生成画面/u })).toBeTruthy();
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: /生成画面/u }),
|
||||
).toBeTruthy();
|
||||
expect(within(dialog).queryByText('画面图')).toBeNull();
|
||||
expect(within(dialog).queryByRole('button', { name: /关卡测试/u })).toBeNull();
|
||||
expect(
|
||||
within(dialog).queryByRole('button', { name: /关卡测试/u }),
|
||||
).toBeNull();
|
||||
fireEvent.click(screen.getByLabelText('关闭'));
|
||||
expect(screen.getAllByText('第2关').length).toBeGreaterThan(0);
|
||||
|
||||
@@ -358,6 +365,7 @@ describe('PuzzleResultView', () => {
|
||||
levelId: 'puzzle-level-1775000000000-2',
|
||||
promptText: '新关卡里有一座发光钟楼。',
|
||||
referenceImageSrc: undefined,
|
||||
imageModel: 'original',
|
||||
candidateCount: 1,
|
||||
levelsJson: expect.any(String),
|
||||
});
|
||||
@@ -457,8 +465,38 @@ describe('PuzzleResultView', () => {
|
||||
levelId: 'puzzle-level-1',
|
||||
promptText: '屋檐下的猫与暖灯街角。',
|
||||
referenceImageSrc: '/generated-puzzle-assets/history/image.png',
|
||||
imageModel: 'original',
|
||||
candidateCount: 1,
|
||||
levelsJson: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
test('passes the selected image model when regenerating a level image', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('雨夜猫街'));
|
||||
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '图片模型' }));
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('menuitemradio', { name: 'gpt-image-2' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: /重新生成画面/u }),
|
||||
);
|
||||
|
||||
expect(onExecuteAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'generate_puzzle_images',
|
||||
imageModel: 'gpt-image-2',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,6 +26,11 @@ import {
|
||||
} from '../../services/puzzle-works/puzzleAssetClient';
|
||||
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import {
|
||||
PUZZLE_IMAGE_MODEL_ORIGINAL,
|
||||
type PuzzleImageModelId,
|
||||
} from '../puzzle-agent/puzzleImageModelOptions';
|
||||
import { PuzzleImageModelPicker } from '../puzzle-agent/PuzzleImageModelPicker';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
|
||||
type PuzzleResultViewProps = {
|
||||
@@ -80,7 +85,9 @@ function resolveLevelFormalImageSrc(level: PuzzleDraftLevel) {
|
||||
);
|
||||
}
|
||||
|
||||
function buildFallbackLevelFromDraft(draft: PuzzleResultDraft): PuzzleDraftLevel {
|
||||
function buildFallbackLevelFromDraft(
|
||||
draft: PuzzleResultDraft,
|
||||
): PuzzleDraftLevel {
|
||||
return {
|
||||
levelId: 'puzzle-level-1',
|
||||
levelName: draft.levelName || '',
|
||||
@@ -143,7 +150,9 @@ function createDraftEditState(draft: PuzzleResultDraft): DraftEditState {
|
||||
};
|
||||
}
|
||||
|
||||
function createBlankPuzzleLevel(existingLevels: PuzzleDraftLevel[]): PuzzleDraftLevel {
|
||||
function createBlankPuzzleLevel(
|
||||
existingLevels: PuzzleDraftLevel[],
|
||||
): PuzzleDraftLevel {
|
||||
const nextIndex = existingLevels.length + 1;
|
||||
return {
|
||||
levelId: `puzzle-level-${Date.now()}-${nextIndex}`,
|
||||
@@ -200,7 +209,9 @@ function buildPublishReady(
|
||||
...(levels.length > 0 ? [] : ['至少需要一个拼图关卡。']),
|
||||
...levels.flatMap((level, index) => [
|
||||
...(level.levelName.trim() ? [] : [`第${index + 1}关名称不能为空。`]),
|
||||
...(resolveLevelFormalImageSrc(level) ? [] : [`第${index + 1}关缺少正式图。`]),
|
||||
...(resolveLevelFormalImageSrc(level)
|
||||
? []
|
||||
: [`第${index + 1}关缺少正式图。`]),
|
||||
]),
|
||||
];
|
||||
|
||||
@@ -574,6 +585,7 @@ function PuzzleLevelDetailDialog({
|
||||
levelId: string,
|
||||
promptText?: string | null,
|
||||
referenceImageSrc?: string | null,
|
||||
imageModel?: PuzzleImageModelId | null,
|
||||
) => void;
|
||||
onLevelChange: (nextLevel: PuzzleDraftLevel) => void;
|
||||
onStartTestRun?: (level: PuzzleDraftLevel) => void;
|
||||
@@ -581,8 +593,13 @@ function PuzzleLevelDetailDialog({
|
||||
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
|
||||
const [referenceImageSrc, setReferenceImageSrc] = useState('');
|
||||
const [referenceImageLabel, setReferenceImageLabel] = useState('');
|
||||
const [referenceImageError, setReferenceImageError] = useState<string | null>(null);
|
||||
const [referenceImageError, setReferenceImageError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [isHistoryPickerOpen, setIsHistoryPickerOpen] = useState(false);
|
||||
const [imageModel, setImageModel] = useState<PuzzleImageModelId>(
|
||||
PUZZLE_IMAGE_MODEL_ORIGINAL,
|
||||
);
|
||||
const formalImageSrc = resolveLevelFormalImageSrc(level);
|
||||
const hasFormalImage = Boolean(formalImageSrc);
|
||||
|
||||
@@ -704,6 +721,11 @@ function PuzzleLevelDetailDialog({
|
||||
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={referenceImageSrc ? '更换参考图' : '添加参考图'}
|
||||
@@ -787,6 +809,7 @@ function PuzzleLevelDetailDialog({
|
||||
level.levelId,
|
||||
level.pictureDescription.trim() || undefined,
|
||||
referenceImageSrc || undefined,
|
||||
imageModel,
|
||||
);
|
||||
}}
|
||||
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"
|
||||
@@ -836,7 +859,9 @@ function PuzzlePublishDialog({
|
||||
}) {
|
||||
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
|
||||
const primaryLevel = editState.levels[0] ?? null;
|
||||
const formalImageSrc = primaryLevel ? resolveLevelFormalImageSrc(primaryLevel) : '';
|
||||
const formalImageSrc = primaryLevel
|
||||
? resolveLevelFormalImageSrc(primaryLevel)
|
||||
: '';
|
||||
|
||||
if (typeof document === 'undefined') {
|
||||
return null;
|
||||
@@ -1180,7 +1205,9 @@ export function PuzzleResultView({
|
||||
return syncDraftFromEditState(draft, editState);
|
||||
}, [draft, editState]);
|
||||
const primaryLevel = editState?.levels[0] ?? null;
|
||||
const primaryImageSrc = primaryLevel ? resolveLevelFormalImageSrc(primaryLevel) : '';
|
||||
const primaryImageSrc = primaryLevel
|
||||
? resolveLevelFormalImageSrc(primaryLevel)
|
||||
: '';
|
||||
const imageRefreshKey = `${session.updatedAt}:${primaryImageSrc}:${editState?.levels.length ?? 0}`;
|
||||
const activeLevel =
|
||||
editState?.levels.find((level) => level.levelId === activeLevelId) ?? null;
|
||||
@@ -1201,7 +1228,8 @@ export function PuzzleResultView({
|
||||
pictureDescription: level.pictureDescription.trim(),
|
||||
})),
|
||||
};
|
||||
const originalState = savedEditStateRef.current ?? createDraftEditState(draft);
|
||||
const originalState =
|
||||
savedEditStateRef.current ?? createDraftEditState(draft);
|
||||
const changed =
|
||||
JSON.stringify(normalizedState) !== JSON.stringify(originalState);
|
||||
|
||||
@@ -1386,12 +1414,13 @@ export function PuzzleResultView({
|
||||
isBusy={isBusy}
|
||||
level={activeLevel}
|
||||
onClose={() => setActiveLevelId(null)}
|
||||
onGenerate={(levelId, promptText, referenceImageSrc) => {
|
||||
onGenerate={(levelId, promptText, referenceImageSrc, imageModel) => {
|
||||
onExecuteAction({
|
||||
action: 'generate_puzzle_images',
|
||||
levelId,
|
||||
promptText,
|
||||
referenceImageSrc,
|
||||
imageModel: imageModel ?? PUZZLE_IMAGE_MODEL_ORIGINAL,
|
||||
candidateCount: 1,
|
||||
levelsJson: JSON.stringify(editState.levels),
|
||||
});
|
||||
|
||||
@@ -1708,7 +1708,7 @@ beforeEach(() => {
|
||||
vi.mocked(streamRpgCreationMessage).mockResolvedValue(mockSession);
|
||||
});
|
||||
|
||||
test('create hub keeps RPG, AIRP and visual novel locked', async () => {
|
||||
test('create hub opens RPG while keeping AIRP and visual novel locked', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
@@ -1724,9 +1724,13 @@ test('create hub keeps RPG, AIRP and visual novel locked', async () => {
|
||||
expect((visualNovelButton as HTMLButtonElement).disabled).toBe(true);
|
||||
const rpgButton = screen.getByRole('button', { name: /角色扮演/u });
|
||||
|
||||
expect((rpgButton as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(within(rpgButton).getAllByText('敬请期待').length).toBeGreaterThan(0);
|
||||
expect(createRpgCreationSession).not.toHaveBeenCalled();
|
||||
expect((rpgButton as HTMLButtonElement).disabled).toBe(false);
|
||||
await user.click(rpgButton);
|
||||
|
||||
expect(createRpgCreationSession).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
await screen.findByText('Agent工作区:custom-world-agent-session-1'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('platform create hub does not prefetch hidden big fish platform data', async () => {
|
||||
@@ -2437,7 +2441,7 @@ test('published puzzle detail returns to the ranking platform tab', async () =>
|
||||
});
|
||||
});
|
||||
|
||||
test('selecting locked RPG creation while logged out does not route through requireAuth', async () => {
|
||||
test('selecting RPG creation while logged out routes through requireAuth', async () => {
|
||||
const user = userEvent.setup();
|
||||
const requireAuth = vi.fn();
|
||||
|
||||
@@ -2454,9 +2458,9 @@ test('selecting locked RPG creation while logged out does not route through requ
|
||||
await openCreationHub(user);
|
||||
const rpgButton = await screen.findByRole('button', { name: /角色扮演/u });
|
||||
|
||||
expect((rpgButton as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((rpgButton as HTMLButtonElement).disabled).toBe(false);
|
||||
await user.click(rpgButton);
|
||||
expect(requireAuth).not.toHaveBeenCalled();
|
||||
expect(requireAuth).toHaveBeenCalledTimes(1);
|
||||
expect(createRpgCreationSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -2582,16 +2586,16 @@ test('new creation entry maps raw bearer token errors to user-facing auth copy',
|
||||
await openCreationHub(user);
|
||||
const rpgButton = screen.getByRole('button', { name: /角色扮演/u });
|
||||
|
||||
expect((rpgButton as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((rpgButton as HTMLButtonElement).disabled).toBe(false);
|
||||
await user.click(rpgButton);
|
||||
|
||||
expect(listPuzzleWorks).toHaveBeenCalled();
|
||||
expect(createRpgCreationSession).not.toHaveBeenCalled();
|
||||
expect(createRpgCreationSession).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
within(getPlatformTabPanel('create')).queryByText(
|
||||
await within(getPlatformTabPanel('create')).findByText(
|
||||
'当前登录状态已失效,请重新登录后继续。',
|
||||
),
|
||||
).toBeNull();
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByText('缺少 Authorization Bearer Token')).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
69
src/config/newWorkEntryConfig.ts
Normal file
69
src/config/newWorkEntryConfig.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* 新建作品入口配置。
|
||||
* 修改入口开放状态、隐藏状态和展示文案时,优先调整本文件,避免多入口文案漂移。
|
||||
*/
|
||||
export const NEW_WORK_ENTRY_CONFIG = {
|
||||
startCard: {
|
||||
title: '新建作品',
|
||||
description: '直接选择游戏创作模板,立刻进入对应的共创工作台。',
|
||||
idleBadge: '选择模板',
|
||||
busyBadge: '正在开启',
|
||||
},
|
||||
typeModal: {
|
||||
title: '选择创作类型',
|
||||
description: '先选玩法类型,再进入对应创作工作台。',
|
||||
},
|
||||
creationTypes: [
|
||||
{
|
||||
id: 'rpg',
|
||||
title: '角色扮演',
|
||||
subtitle: '敬请期待',
|
||||
badge: '敬请期待',
|
||||
visible: true,
|
||||
open: true,
|
||||
},
|
||||
{
|
||||
id: 'big-fish',
|
||||
title: '大鱼吃小鱼',
|
||||
subtitle: '实时成长玩法',
|
||||
badge: '可创建',
|
||||
visible: false,
|
||||
open: true,
|
||||
},
|
||||
{
|
||||
id: 'puzzle',
|
||||
title: '拼图',
|
||||
subtitle: '创意礼物,生活分享',
|
||||
badge: '可创建',
|
||||
visible: true,
|
||||
open: true,
|
||||
},
|
||||
{
|
||||
id: 'match3d',
|
||||
title: '抓大鹅',
|
||||
subtitle: '敬请期待',
|
||||
badge: '敬请期待',
|
||||
visible: true,
|
||||
open: false,
|
||||
},
|
||||
{
|
||||
id: 'airp',
|
||||
title: 'AIRP',
|
||||
subtitle: '敬请期待',
|
||||
badge: '敬请期待',
|
||||
visible: true,
|
||||
open: false,
|
||||
},
|
||||
{
|
||||
id: 'visual-novel',
|
||||
title: '视觉小说',
|
||||
subtitle: '敬请期待',
|
||||
badge: '敬请期待',
|
||||
visible: true,
|
||||
open: false,
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
export type NewWorkEntryCreationTypeId =
|
||||
(typeof NEW_WORK_ENTRY_CONFIG.creationTypes)[number]['id'];
|
||||
Reference in New Issue
Block a user