Puzzle: support history images & partial generation

Allow history-generated image paths to be submitted where Data URLs were previously required and avoid treating partial/result-page generations as blocking the whole draft. Backend: resolve history /generated-* references via resolve_puzzle_reference_image_as_data_url and convert to PuzzleDownloadedImage; add PuzzleDownloadedImage::from_resolved_reference_image; extend draft handling to apply generated level metadata (auto-naming) and normalize generation_status to treat levels with images as ready. API: add shouldAutoNameLevel to action contracts and use it to request/refine generated level names. Spacetime/module and mappers: normalize completed level statuses when saving/reading so result-page background or per-level generation doesn't mask completed drafts. Frontend: expose resolver helpers, only mark a work as generating when no usable cover or ready level exists, keep level controls enabled during UI-background regeneration, and add tests covering history-image submission, auto-naming, and UI-background/partial-generation behaviors.
This commit is contained in:
2026-05-19 10:02:13 +08:00
parent 5e03b3d2f2
commit 7b37271f17
16 changed files with 653 additions and 73 deletions

View File

@@ -221,6 +221,81 @@ test('creation hub marks generating and newly completed drafts', () => {
expect(html).toContain('creation-work-card__spinner');
});
test('creation hub does not mask completed puzzle drafts while a later level image is generating', () => {
const html = renderToStaticMarkup(
<CustomWorldCreationHub
items={[]}
puzzleItems={[
{
workId: 'puzzle-work-session-1',
profileId: 'puzzle-profile-session-1',
ownerUserId: 'user-1',
authorDisplayName: '测试作者',
workTitle: '潮雾拼图草稿',
workDescription: '已经有可查看的首关结果。',
levelName: '潮雾拼图',
summary: '已经有可查看的首关结果。',
themeTags: ['潮雾'],
coverImageSrc: '/generated-puzzle-assets/session/cover.png',
publicationStatus: 'draft',
updatedAt: new Date('2026-04-22T10:00:00.000Z').toISOString(),
publishedAt: null,
publishReady: false,
sourceSessionId: 'puzzle-session-1',
generationStatus: 'generating',
levels: [
{
levelId: 'puzzle-level-1',
levelName: '潮雾拼图',
pictureDescription: '潮雾港口。',
pictureReference: null,
candidates: [
{
candidateId: 'candidate-1',
imageSrc: '/generated-puzzle-assets/session/cover.png',
assetId: 'asset-1',
prompt: '潮雾港口',
actualPrompt: null,
sourceType: 'generated',
selected: true,
},
],
selectedCandidateId: 'candidate-1',
coverImageSrc: '/generated-puzzle-assets/session/cover.png',
coverAssetId: 'asset-1',
generationStatus: 'ready',
},
{
levelId: 'puzzle-level-2',
levelName: '灯塔',
pictureDescription: '灯塔新关卡。',
pictureReference: null,
candidates: [],
selectedCandidateId: null,
coverImageSrc: null,
coverAssetId: null,
generationStatus: 'generating',
},
],
},
]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
onOpenPuzzleDetail={() => {}}
/>,
);
expect(html).not.toContain('生成中...');
expect(html).not.toContain('creation-work-card__spinner');
expect(html).toContain('继续创作《潮雾拼图草稿》');
});
test('creation hub published work uses unified list card layout', () => {
const html = renderToStaticMarkup(
<CustomWorldCreationHub

View File

@@ -641,7 +641,7 @@ function isCreationTypeReferenceCoverImageSrc(value?: string | null) {
);
}
function resolvePuzzleWorkCoverImageSrc(item: PuzzleWorkSummary) {
export function resolvePuzzleWorkCoverImageSrc(item: PuzzleWorkSummary) {
const directCoverImageSrc = normalizeCoverImageSrc(item.coverImageSrc);
if (
directCoverImageSrc &&
@@ -651,33 +651,44 @@ function resolvePuzzleWorkCoverImageSrc(item: PuzzleWorkSummary) {
}
for (const level of item.levels ?? []) {
const levelCoverImageSrc = normalizeCoverImageSrc(level.coverImageSrc);
if (
levelCoverImageSrc &&
!isCreationTypeReferenceCoverImageSrc(levelCoverImageSrc)
) {
return levelCoverImageSrc;
const levelImageSrc = resolvePuzzleLevelCoverImageSrc(level);
if (levelImageSrc) {
return levelImageSrc;
}
}
const selectedCandidateImageSrc =
level.selectedCandidateId && level.candidates.length > 0
? normalizeCoverImageSrc(
level.candidates.find(
(candidate) => candidate.candidateId === level.selectedCandidateId,
)?.imageSrc,
return null;
}
export function resolvePuzzleLevelCoverImageSrc(
level: NonNullable<PuzzleWorkSummary['levels']>[number],
) {
const levelCoverImageSrc = normalizeCoverImageSrc(level.coverImageSrc);
if (
levelCoverImageSrc &&
!isCreationTypeReferenceCoverImageSrc(levelCoverImageSrc)
) {
return levelCoverImageSrc;
}
const selectedCandidateImageSrc =
level.selectedCandidateId && level.candidates.length > 0
? normalizeCoverImageSrc(
level.candidates.find(
(candidate) => candidate.candidateId === level.selectedCandidateId,
)?.imageSrc,
)
: null;
const fallbackCandidateImageSrc = normalizeCoverImageSrc(
level.candidates[level.candidates.length - 1]?.imageSrc,
);
const candidateImageSrc = selectedCandidateImageSrc || fallbackCandidateImageSrc;
: null;
const fallbackCandidateImageSrc = normalizeCoverImageSrc(
level.candidates[level.candidates.length - 1]?.imageSrc,
);
const candidateImageSrc = selectedCandidateImageSrc || fallbackCandidateImageSrc;
if (
candidateImageSrc &&
!isCreationTypeReferenceCoverImageSrc(candidateImageSrc)
) {
return candidateImageSrc;
}
if (
candidateImageSrc &&
!isCreationTypeReferenceCoverImageSrc(candidateImageSrc)
) {
return candidateImageSrc;
}
return null;
@@ -804,12 +815,26 @@ function isPersistedCreationWorkGenerating(item: CreationWorkShelfItem) {
case 'match3d':
return item.source.item.generationStatus === 'generating';
case 'puzzle':
return item.source.item.generationStatus === 'generating';
return isPersistedPuzzleDraftGenerating(item.source.item);
default:
return false;
}
}
export function isPersistedPuzzleDraftGenerating(item: PuzzleWorkSummary) {
if (item.generationStatus !== 'generating') {
return false;
}
const hasUsableCover = Boolean(resolvePuzzleWorkCoverImageSrc(item));
const hasReadyLevel = (item.levels ?? []).some((level) =>
Boolean(resolvePuzzleLevelCoverImageSrc(level)),
);
// 中文注释:作品架“生成中”只表示初始草稿还没有可查看结果;结果页追加关卡或重绘局部图片不能锁住整张草稿卡。
return !hasUsableCover && !hasReadyLevel;
}
function buildRpgWorkShelfActions(
item: CustomWorldWorkSummary,
adapter: RpgWorkShelfAdapter,