合并 master 到图片画布素材分支

保留图片画布素材元数据迁移记录

合入 Pingora 网关与生产运维更新

解决外部生成 worker 测试夹具时间戳冲突
This commit is contained in:
2026-06-19 17:01:31 +08:00
92 changed files with 59009 additions and 501 deletions
@@ -167,6 +167,7 @@ import {
fetchCreationEntryConfig,
} from '../../services/creationEntryConfigService';
import {
type CreationUrlState,
clearCreationUrlState,
readCreationUrlState,
writeCreationUrlState,
@@ -501,6 +502,7 @@ import {
type PendingDraftShelfKind,
type PendingDraftShelfMap,
type PendingDraftShelfMetadata,
buildPuzzleDraftOpenNoticeKeys,
resolveBigFishDraftOpenIntent,
resolveJumpHopDraftOpenIntent,
resolveMatch3DDraftOpenIntent,
@@ -912,6 +914,29 @@ function pushPuzzleResultHistoryEntry(
writeCreationUrlState(buildPuzzleCreationUrlState(session));
}
function pushPuzzleGeneratingHistoryEntry(
session: PuzzleAgentSessionSnapshot | null,
fallbackState?: CreationUrlState,
) {
pushAppHistoryPath('/creation/puzzle/generating');
writeCreationUrlState(
session ? buildPuzzleCreationUrlState(session) : (fallbackState ?? {}),
);
}
function buildPuzzleCreationUrlStateFromWork(
item: PuzzleWorkSummary,
): CreationUrlState {
const sourceSessionId = item.sourceSessionId?.trim() || null;
return {
sessionId: sourceSessionId,
profileId:
item.profileId?.trim() || buildPuzzleResultProfileId(sourceSessionId),
workId: item.workId?.trim() || buildPuzzleResultWorkId(sourceSessionId),
};
}
const PUZZLE_DRAFT_GENERATION_POINT_COST = 2;
const PUZZLE_BACKGROUND_ACTION_POLL_INTERVAL_MS = 3000;
const PUZZLE_BACKGROUND_ACTION_MAX_POLL_ATTEMPTS = 160;
@@ -4291,6 +4316,7 @@ export function PlatformEntryFlowShellImpl({
);
activePuzzleGenerationSessionIdRef.current =
response.session.sessionId;
pushPuzzleGeneratingHistoryEntry(response.session);
setSelectionStage('puzzle-generating');
markDraftGenerating('puzzle', [
response.session.sessionId,
@@ -4435,6 +4461,7 @@ export function PlatformEntryFlowShellImpl({
);
selectionStageRef.current = 'puzzle-generating';
activePuzzleGenerationSessionIdRef.current = session.sessionId;
pushPuzzleGeneratingHistoryEntry(session);
setSelectionStage('puzzle-generating');
const nextGenerationState = createPuzzleDraftGenerationStateFromPayload(
formPayload ?? buildPuzzleFormPayloadFromSession(session),
@@ -5707,6 +5734,7 @@ export function PlatformEntryFlowShellImpl({
}
selectionStageRef.current = 'puzzle-generating';
activePuzzleGenerationSessionIdRef.current = nextSession.sessionId;
pushPuzzleGeneratingHistoryEntry(nextSession);
setSelectionStage('puzzle-generating');
try {
@@ -12110,6 +12138,66 @@ export function PlatformEntryFlowShellImpl({
setPuzzleRun(null);
setPuzzleRuntimeAuthMode('default');
setSelectedPuzzleDetail(null);
const restorePuzzleGeneratingSession = (
latestSession: PuzzleAgentSessionSnapshot,
) => {
const payload = buildPuzzleFormPayloadFromSession(latestSession);
const startedAtMs = resolveMiniGameDraftGenerationStartedAtMs(
latestSession.updatedAt,
);
const baseGenerationState =
createMiniGameDraftGenerationStateForRestoredDraft(
'puzzle',
{
puzzleAiRedraw: payload.aiRedraw ?? true,
puzzleProgressPercent:
latestSession.draft && !latestSession.draft.formDraft
? latestSession.progressPercent
: undefined,
},
startedAtMs,
);
const generationState = mergePuzzleSessionProgressIntoGenerationState(
baseGenerationState,
latestSession,
);
puzzleFlow.setSession(latestSession);
setPuzzleFormDraftPayload(payload);
setPuzzleGenerationState(generationState);
setPuzzleBackgroundCompileTasks((current) => ({
...current,
[latestSession.sessionId]: {
session: latestSession,
payload,
generationState,
error: null,
},
}));
enterCreateTab();
selectionStageRef.current = 'puzzle-generating';
activePuzzleGenerationSessionIdRef.current = latestSession.sessionId;
pushPuzzleGeneratingHistoryEntry(latestSession);
setSelectionStage('puzzle-generating');
};
const openPuzzleReadyResultSession = (
latestSession: PuzzleAgentSessionSnapshot,
) => {
const payload = buildPuzzleFormPayloadFromSession(latestSession);
puzzleFlow.setSession(latestSession);
setPuzzleFormDraftPayload(payload);
setPuzzleGenerationState(null);
setPuzzleBackgroundCompileTasks((current) => {
const next = { ...current };
delete next[latestSession.sessionId];
return next;
});
enterCreateTab();
selectionStageRef.current = 'puzzle-result';
activePuzzleGenerationSessionIdRef.current = null;
markDraftNoticeSeen(buildPuzzleDraftOpenNoticeKeys(item));
pushPuzzleResultHistoryEntry(latestSession);
setSelectionStage('puzzle-result');
};
if (openIntent.type === 'open-published-detail') {
await openPuzzleDetail(item.profileId, { tab: 'create' });
@@ -12175,6 +12263,10 @@ export function PlatformEntryFlowShellImpl({
enterCreateTab();
selectionStageRef.current = 'puzzle-generating';
activePuzzleGenerationSessionIdRef.current = sourceSessionId;
pushPuzzleGeneratingHistoryEntry(
failedSession,
buildPuzzleCreationUrlStateFromWork(item),
);
setPuzzleGenerationState(failedGenerationState);
setSelectionStage('puzzle-generating');
return;
@@ -12189,6 +12281,10 @@ export function PlatformEntryFlowShellImpl({
enterCreateTab();
selectionStageRef.current = 'puzzle-generating';
activePuzzleGenerationSessionIdRef.current = sourceSessionId;
pushPuzzleGeneratingHistoryEntry(
puzzleSession ?? backgroundTask?.session ?? null,
buildPuzzleCreationUrlStateFromWork(item),
);
setPuzzleGenerationState(rebasedGenerationState);
if (backgroundTask) {
setPuzzleBackgroundCompileTasks((current) => ({
@@ -12219,6 +12315,7 @@ export function PlatformEntryFlowShellImpl({
enterCreateTab();
selectionStageRef.current = 'puzzle-generating';
activePuzzleGenerationSessionIdRef.current = sourceSessionId;
pushPuzzleGeneratingHistoryEntry(rebasedTask.session);
setSelectionStage('puzzle-generating');
return;
}
@@ -12227,42 +12324,14 @@ export function PlatformEntryFlowShellImpl({
try {
const { session: latestSession } =
await getPuzzleAgentSession(sourceSessionId);
const payload = buildPuzzleFormPayloadFromSession(latestSession);
const startedAtMs = resolveMiniGameDraftGenerationStartedAtMs(
latestSession.updatedAt,
);
const baseGenerationState =
createMiniGameDraftGenerationStateForRestoredDraft(
'puzzle',
{
puzzleAiRedraw: payload.aiRedraw ?? true,
puzzleProgressPercent:
latestSession.draft && !latestSession.draft.formDraft
? latestSession.progressPercent
: undefined,
},
startedAtMs,
);
const generationState = mergePuzzleSessionProgressIntoGenerationState(
baseGenerationState,
latestSession,
);
puzzleFlow.setSession(latestSession);
setPuzzleFormDraftPayload(payload);
setPuzzleGenerationState(generationState);
setPuzzleBackgroundCompileTasks((current) => ({
...current,
[latestSession.sessionId]: {
session: latestSession,
payload,
generationState,
error: null,
},
}));
enterCreateTab();
selectionStageRef.current = 'puzzle-generating';
activePuzzleGenerationSessionIdRef.current = sourceSessionId;
setSelectionStage('puzzle-generating');
if (
!isPuzzleFormOnlyDraft(latestSession) &&
isPuzzleCompileActionReady(latestSession)
) {
openPuzzleReadyResultSession(latestSession);
return;
}
restorePuzzleGeneratingSession(latestSession);
return;
} catch (error) {
setPuzzleError(
@@ -12288,6 +12357,10 @@ export function PlatformEntryFlowShellImpl({
setSelectionStage('puzzle-agent-workspace');
} else {
setPuzzleFormDraftPayload(null);
pushPuzzleResultHistoryEntry(restoredSession);
selectionStageRef.current = 'puzzle-result';
activePuzzleGenerationSessionIdRef.current = null;
setSelectionStage('puzzle-result');
}
},
[
@@ -99,6 +99,58 @@ describe('platformDraftGenerationShelfModel', () => {
});
});
test('resolvePuzzleDraftOpenIntent ignores stale generating notice after puzzle draft is ready', () => {
expect(
resolvePuzzleDraftOpenIntent({
item: buildPuzzleWork({
generationStatus: 'ready',
coverImageSrc: '/generated-puzzle-assets/session/cover.png',
levels: [
{
levelId: 'level-1',
levelName: '金色数字舞台',
pictureDescription: '123456',
pictureReference: null,
candidates: [],
selectedCandidateId: null,
coverImageSrc: '/generated-puzzle-assets/session/cover.png',
coverAssetId: 'asset-1',
generationStatus: 'ready',
},
],
}),
notices: {
'puzzle:puzzle-session-base': {
status: 'generating',
seen: false,
},
},
generation: emptyGenerationFacts(),
}),
).toMatchObject({
type: 'restore-draft',
});
});
test('resolvePuzzleDraftOpenIntent restores generating page from notice even before persisted status catches up', () => {
expect(
resolvePuzzleDraftOpenIntent({
item: buildPuzzleWork({
generationStatus: 'idle',
}),
notices: {
'puzzle:puzzle-session-base': {
status: 'generating',
seen: false,
},
},
generation: emptyGenerationFacts(),
}),
).toMatchObject({
type: 'restore-generating',
});
});
test('resolveMatch3DDraftOpenIntent opens published work detail unless forced into draft', () => {
const item = buildMatch3DWork({
publicationStatus: 'published',
@@ -595,7 +595,7 @@ describe('PuzzleResultView', () => {
);
});
test('adds and deletes levels from the list', async () => {
test('keeps a blank new level local until the creator edits it', async () => {
vi.useFakeTimers();
vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({
item: {} as never,
@@ -621,25 +621,36 @@ describe('PuzzleResultView', () => {
expect(
within(dialog).queryByRole('button', { name: /关卡测试/u }),
).toBeNull();
fireEvent.click(screen.getByLabelText('关闭关卡详情'));
expect(screen.getAllByText('第2关').length).toBeGreaterThan(0);
await act(async () => {
await vi.runAllTimersAsync();
});
expect(puzzleWorksService.updatePuzzleWork).toHaveBeenLastCalledWith(
expect(screen.getByRole('dialog', { name: '关卡详情' })).toBeTruthy();
expect(screen.queryByText('保存中')).toBeNull();
expect(puzzleWorksService.updatePuzzleWork).not.toHaveBeenCalled();
fireEvent.change(within(dialog).getByLabelText('关卡名称'), {
target: { value: '钟楼猫街' },
});
await act(async () => {
await vi.runAllTimersAsync();
});
expect(puzzleWorksService.updatePuzzleWork).toHaveBeenCalledWith(
'puzzle-profile-session-1',
expect.objectContaining({
levels: expect.arrayContaining([
expect.objectContaining({ levelId: 'puzzle-level-1' }),
expect.objectContaining({ levelName: '' }),
expect.objectContaining({ levelName: '钟楼猫街' }),
]),
}),
);
fireEvent.click(screen.getByLabelText('删除关卡 第2关'));
expect(screen.queryByText('第2关')).toBeNull();
fireEvent.click(screen.getByLabelText('关闭关卡详情'));
fireEvent.click(screen.getByLabelText('删除关卡 钟楼猫街'));
expect(screen.queryByText('钟楼猫街')).toBeNull();
await act(async () => {
await vi.runAllTimersAsync();
@@ -657,6 +668,48 @@ describe('PuzzleResultView', () => {
);
});
test('keeps the blank level dialog open across incoming draft refreshes', async () => {
vi.useFakeTimers();
const base = createSession();
const { rerender } = render(
<PuzzleResultView
session={base}
profileId="puzzle-profile-session-1"
onBack={() => {}}
onExecuteAction={() => {}}
/>,
);
openPuzzleLevelsTab();
fireEvent.click(screen.getByRole('button', { name: /新增关卡/u }));
expect(screen.getByRole('dialog', { name: '关卡详情' })).toBeTruthy();
rerender(
<PuzzleResultView
session={{
...base,
updatedAt: '2026-04-26T10:00:01.000Z',
}}
profileId="puzzle-profile-session-1"
onBack={() => {}}
onExecuteAction={() => {}}
/>,
);
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
expect(
(within(dialog).getByLabelText('关卡名称') as HTMLInputElement).value,
).toBe('');
expect(screen.getByLabelText('删除关卡 第2关')).toBeTruthy();
expect(screen.queryByText('保存中')).toBeNull();
await act(async () => {
await vi.runAllTimersAsync();
});
expect(puzzleWorksService.updatePuzzleWork).not.toHaveBeenCalled();
});
test('generates image for a newly added level with the current levels snapshot', () => {
vi.spyOn(Date, 'now').mockReturnValue(1_775_000_000_000);
const onExecuteAction = vi.fn();
+106 -28
View File
@@ -166,6 +166,96 @@ function resolveLevelFormalImageSrc(level: PuzzleDraftLevel) {
);
}
function normalizeOptionalPuzzleLevelText(value?: string | null) {
return value?.trim() || null;
}
function normalizePuzzleLevelForSave(level: PuzzleDraftLevel) {
return {
...level,
levelName: level.levelName.trim(),
pictureDescription: level.pictureDescription.trim(),
pictureReference: normalizeOptionalPuzzleLevelText(level.pictureReference),
uiBackgroundPrompt: normalizeOptionalPuzzleLevelText(
level.uiBackgroundPrompt,
),
uiBackgroundImageSrc: normalizeOptionalPuzzleLevelText(
level.uiBackgroundImageSrc,
),
uiBackgroundImageObjectKey: normalizeOptionalPuzzleLevelText(
level.uiBackgroundImageObjectKey,
),
levelSceneImageSrc: normalizeOptionalPuzzleLevelText(
level.levelSceneImageSrc,
),
levelSceneImageObjectKey: normalizeOptionalPuzzleLevelText(
level.levelSceneImageObjectKey,
),
uiSpritesheetImageSrc: normalizeOptionalPuzzleLevelText(
level.uiSpritesheetImageSrc,
),
uiSpritesheetImageObjectKey: normalizeOptionalPuzzleLevelText(
level.uiSpritesheetImageObjectKey,
),
levelBackgroundImageSrc: normalizeOptionalPuzzleLevelText(
level.levelBackgroundImageSrc,
),
levelBackgroundImageObjectKey: normalizeOptionalPuzzleLevelText(
level.levelBackgroundImageObjectKey,
),
generationStatus: level.generationStatus || 'idle',
};
}
function normalizeDraftEditStateForSave(state: DraftEditState): DraftEditState {
return {
...state,
workTitle: state.workTitle.trim(),
workDescription: state.workDescription.trim(),
themeTags: normalizeThemeTagInput(state.themeTags.join('')),
levels: state.levels.map(normalizePuzzleLevelForSave),
};
}
function isBlankPuzzleLevel(level: PuzzleDraftLevel) {
return (
!level.levelName.trim() &&
!level.pictureDescription.trim() &&
!level.pictureReference?.trim() &&
!level.uiBackgroundPrompt?.trim() &&
!level.uiBackgroundImageSrc?.trim() &&
!level.uiBackgroundImageObjectKey?.trim() &&
!level.levelSceneImageSrc?.trim() &&
!level.levelSceneImageObjectKey?.trim() &&
!level.uiSpritesheetImageSrc?.trim() &&
!level.uiSpritesheetImageObjectKey?.trim() &&
!level.levelBackgroundImageSrc?.trim() &&
!level.levelBackgroundImageObjectKey?.trim() &&
!level.selectedCandidateId?.trim() &&
!level.coverImageSrc?.trim() &&
!level.coverAssetId?.trim() &&
!level.backgroundMusic &&
level.candidates.length === 0 &&
(level.generationStatus || 'idle') === 'idle'
);
}
function removeUnsavedBlankPuzzleLevels(
state: DraftEditState,
originalState: DraftEditState,
) {
const originalLevelIds = new Set(
originalState.levels.map((level) => level.levelId),
);
return {
...state,
levels: state.levels.filter(
(level) =>
originalLevelIds.has(level.levelId) || !isBlankPuzzleLevel(level),
),
};
}
function buildFallbackLevelFromDraft(
draft: PuzzleResultDraft,
): PuzzleDraftLevel {
@@ -261,13 +351,20 @@ function mergeDraftEditStateWithIncomingState(
incomingLevel?.generationStatus !== 'generating'
);
});
const hasLocalBlankLevel = currentState.levels.some(
(level) =>
!incomingLevelsById.has(level.levelId) && isBlankPuzzleLevel(level),
);
if (!shouldPreserveLocalEdits) {
if (!shouldPreserveLocalEdits && !hasLocalBlankLevel) {
return incomingState;
}
const mergedLevels = currentState.levels.map((level) => {
const incomingLevel = incomingLevelsById.get(level.levelId);
if (!incomingLevel && isBlankPuzzleLevel(level)) {
return level;
}
if (
!incomingLevel ||
level.generationStatus !== 'generating' ||
@@ -1411,36 +1508,17 @@ export function PuzzleResultView({
return;
}
const normalizedState: DraftEditState = {
...editState,
workTitle: editState.workTitle.trim(),
workDescription: editState.workDescription.trim(),
themeTags: normalizeThemeTagInput(editState.themeTags.join('')),
levels: editState.levels.map((level) => ({
...level,
levelName: level.levelName.trim(),
pictureDescription: level.pictureDescription.trim(),
pictureReference: level.pictureReference?.trim() || null,
uiBackgroundPrompt: level.uiBackgroundPrompt?.trim() || null,
uiBackgroundImageSrc: level.uiBackgroundImageSrc?.trim() || null,
uiBackgroundImageObjectKey:
level.uiBackgroundImageObjectKey?.trim() || null,
levelSceneImageSrc: level.levelSceneImageSrc?.trim() || null,
levelSceneImageObjectKey:
level.levelSceneImageObjectKey?.trim() || null,
uiSpritesheetImageSrc: level.uiSpritesheetImageSrc?.trim() || null,
uiSpritesheetImageObjectKey:
level.uiSpritesheetImageObjectKey?.trim() || null,
levelBackgroundImageSrc: level.levelBackgroundImageSrc?.trim() || null,
levelBackgroundImageObjectKey:
level.levelBackgroundImageObjectKey?.trim() || null,
generationStatus: level.generationStatus || 'idle',
})),
};
const originalState =
savedEditStateRef.current ?? createDraftEditState(draft);
const normalizedOriginalState =
normalizeDraftEditStateForSave(originalState);
const normalizedState = removeUnsavedBlankPuzzleLevels(
normalizeDraftEditStateForSave(editState),
normalizedOriginalState,
);
const changed =
JSON.stringify(normalizedState) !== JSON.stringify(originalState);
JSON.stringify(normalizedState) !==
JSON.stringify(normalizedOriginalState);
if (!changed || normalizedState.levels.length <= 0) {
return;