import {
ArrowLeft,
CheckCircle2,
ImagePlus,
Images,
Loader2,
Play,
Plus,
Sparkles,
Trash2,
X,
} from 'lucide-react';
import { type ChangeEvent, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions';
import type {
PuzzleDraftLevel,
PuzzleResultDraft,
} 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 { ResolvedAssetImage } from '../ResolvedAssetImage';
type PuzzleResultViewProps = {
session: PuzzleAgentSessionSnapshot;
profileId?: string | null;
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onExecuteAction: (payload: PuzzleAgentActionRequest) => void;
onStartTestRun?: (draft: PuzzleResultDraft) => void;
};
type PuzzleAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
type PuzzleResultTab = 'levels' | 'work';
type DraftEditState = {
workTitle: string;
workDescription: string;
themeTags: string[];
levels: PuzzleDraftLevel[];
};
const PUZZLE_MIN_THEME_TAG_COUNT = 3;
const PUZZLE_MAX_THEME_TAG_COUNT = 6;
const PUZZLE_AUTOSAVE_DEBOUNCE_MS = 600;
function normalizeThemeTagInput(value: string) {
return [
...new Set(
value
.split(/[\n,,、]/u)
.map((entry) => entry.trim())
.filter(Boolean),
),
];
}
function resolveLevelFormalImageSrc(level: PuzzleDraftLevel) {
const selectedCandidate =
level.candidates.find(
(candidate) =>
candidate.selected ||
(level.selectedCandidateId
? candidate.candidateId === level.selectedCandidateId
: false),
) ??
level.candidates[level.candidates.length - 1] ??
null;
return (
selectedCandidate?.imageSrc?.trim() || level.coverImageSrc?.trim() || ''
);
}
function buildFallbackLevelFromDraft(draft: PuzzleResultDraft): PuzzleDraftLevel {
return {
levelId: 'puzzle-level-1',
levelName: draft.levelName || '',
pictureDescription: draft.summary,
candidates: draft.candidates,
selectedCandidateId: draft.selectedCandidateId,
coverImageSrc: draft.coverImageSrc,
coverAssetId: draft.coverAssetId,
generationStatus: draft.generationStatus,
};
}
function normalizeDraftLevels(draft: PuzzleResultDraft) {
const sourceLevels =
draft.levels && draft.levels.length > 0
? draft.levels
: [buildFallbackLevelFromDraft(draft)];
return sourceLevels.map((level, index) => ({
...level,
levelId: level.levelId?.trim() || `puzzle-level-${index + 1}`,
levelName: level.levelName?.trim() || '',
pictureDescription: level.pictureDescription?.trim() || draft.summary,
candidates: level.candidates ?? [],
selectedCandidateId: level.selectedCandidateId ?? null,
coverImageSrc: level.coverImageSrc ?? null,
coverAssetId: level.coverAssetId ?? null,
generationStatus: level.generationStatus || 'idle',
}));
}
function syncDraftFromEditState(
draft: PuzzleResultDraft,
editState: DraftEditState,
): PuzzleResultDraft {
const levels = editState.levels;
const primaryLevel = levels[0] ?? buildFallbackLevelFromDraft(draft);
return {
...draft,
workTitle: editState.workTitle.trim() || draft.workTitle,
workDescription: editState.workDescription.trim(),
levelName: primaryLevel.levelName,
summary: primaryLevel.pictureDescription,
themeTags: editState.themeTags,
candidates: primaryLevel.candidates,
selectedCandidateId: primaryLevel.selectedCandidateId,
coverImageSrc: primaryLevel.coverImageSrc,
coverAssetId: primaryLevel.coverAssetId,
generationStatus: primaryLevel.generationStatus,
levels,
};
}
function createDraftEditState(draft: PuzzleResultDraft): DraftEditState {
return {
workTitle: draft.workTitle || draft.levelName,
workDescription: draft.workDescription || '',
themeTags: normalizeThemeTagInput(draft.themeTags.join(',')),
levels: normalizeDraftLevels(draft),
};
}
function createBlankPuzzleLevel(existingLevels: PuzzleDraftLevel[]): PuzzleDraftLevel {
const nextIndex = existingLevels.length + 1;
return {
levelId: `puzzle-level-${Date.now()}-${nextIndex}`,
levelName: '',
pictureDescription: '',
candidates: [],
selectedCandidateId: null,
coverImageSrc: null,
coverAssetId: null,
generationStatus: 'idle',
};
}
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,
) {
const preservedBlockers =
session.resultPreview?.blockers
.filter(
(entry) =>
![
'MISSING_LEVEL_NAME',
'INVALID_TAG_COUNT',
'MISSING_COVER_IMAGE',
].includes(entry.code),
)
.map((entry) => entry.message) ?? [];
const levels = editState.levels;
const blockers = [
...(session.resultPreview ? [] : ['等待结果页草稿完成后再发布。']),
...preservedBlockers,
...(editState.workTitle.trim() ? [] : ['作品名称不能为空。']),
...(editState.workDescription.trim() ? [] : ['作品描述不能为空。']),
...(editState.themeTags.length >= PUZZLE_MIN_THEME_TAG_COUNT &&
editState.themeTags.length <= PUZZLE_MAX_THEME_TAG_COUNT
? []
: [
`正式标签数量必须在 ${PUZZLE_MIN_THEME_TAG_COUNT} 到 ${PUZZLE_MAX_THEME_TAG_COUNT} 之间。`,
]),
...(levels.length > 0 ? [] : ['至少需要一个拼图关卡。']),
...levels.flatMap((level, index) => [
...(level.levelName.trim() ? [] : [`第${index + 1}关名称不能为空。`]),
...(resolveLevelFormalImageSrc(level) ? [] : [`第${index + 1}关缺少正式图。`]),
]),
];
return {
blockers: [...new Set(blockers.filter(Boolean))],
publishReady:
Boolean(session.resultPreview?.publishReady) &&
Boolean(editState.workTitle.trim()) &&
Boolean(editState.workDescription.trim()) &&
editState.themeTags.length >= PUZZLE_MIN_THEME_TAG_COUNT &&
editState.themeTags.length <= PUZZLE_MAX_THEME_TAG_COUNT &&
levels.length > 0 &&
levels.every(
(level) => level.levelName.trim() && resolveLevelFormalImageSrc(level),
),
};
}
function PuzzleResultHeader({
autoSaveState,
isBusy,
onBack,
}: {
autoSaveState: PuzzleAutoSaveState;
isBusy: boolean;
onBack: () => void;
}) {
const autoSaveBadge =
autoSaveState === 'saving' ? (
保存中
) : autoSaveState === 'saved' ? (
已自动保存
) : autoSaveState === 'error' ? (
保存失败
) : null;
return (
);
}
function PuzzleResultTabs({
activeTab,
onChange,
}: {
activeTab: PuzzleResultTab;
onChange: (tab: PuzzleResultTab) => void;
}) {
return (
{[
{ id: 'levels' as const, label: '拼图关卡' },
{ id: 'work' as const, label: '作品信息' },
].map((tab) => (
))}
);
}
function PuzzleThemeTagEditor({
editState,
isBusy,
onChange,
}: {
editState: DraftEditState;
isBusy: boolean;
onChange: (nextState: DraftEditState) => void;
}) {
const [newTagText, setNewTagText] = useState('');
const [isAddingTag, setIsAddingTag] = useState(false);
const addTags = () => {
const nextTags = normalizeThemeTagInput(newTagText);
if (nextTags.length <= 0) {
setIsAddingTag(false);
setNewTagText('');
return;
}
onChange({
...editState,
themeTags: [...new Set([...editState.themeTags, ...nextTags])],
});
setNewTagText('');
setIsAddingTag(false);
};
return (
作品标签
{!isAddingTag ? (
) : null}
{editState.themeTags.map((tag) => (
{tag}
))}
{editState.themeTags.length <= 0 ? (
暂无标签
) : null}
{isAddingTag ? (
) : null}
);
}
function PuzzleHistoryAssetPickerDialog({
isBusy,
onClose,
onSelect,
}: {
isBusy: boolean;
onClose: () => void;
onSelect: (asset: PuzzleHistoryAsset) => void;
}) {
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
const [assets, setAssets] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(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(
{
if (event.target === event.currentTarget) {
onClose();
}
}}
>
event.stopPropagation()}
>
{error ? (
{error}
) : null}
{isLoading ? (
读取中...
) : null}
{!isLoading && !error && assets.length <= 0 ? (
暂无历史拼图素材
) : null}
{!isLoading && assets.length > 0 ? (
{assets.map((asset) => (
))}
) : null}
,
document.body,
);
}
function PuzzleLevelDetailDialog({
draft,
imageRefreshKey,
isBusy,
level,
onClose,
onGenerate,
onLevelChange,
onStartTestRun,
}: {
draft: PuzzleResultDraft;
imageRefreshKey: string;
isBusy: boolean;
level: PuzzleDraftLevel;
onClose: () => void;
onGenerate: (
levelId: string,
promptText?: string | null,
referenceImageSrc?: string | null,
) => void;
onLevelChange: (nextLevel: PuzzleDraftLevel) => void;
onStartTestRun?: (level: PuzzleDraftLevel) => void;
}) {
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
const [referenceImageSrc, setReferenceImageSrc] = useState('');
const [referenceImageLabel, setReferenceImageLabel] = useState('');
const [referenceImageError, setReferenceImageError] = useState(null);
const [isHistoryPickerOpen, setIsHistoryPickerOpen] = useState(false);
const formalImageSrc = resolveLevelFormalImageSrc(level);
const hasFormalImage = Boolean(formalImageSrc);
const handleReferenceImageChange = async (
event: ChangeEvent,
) => {
const file = event.target.files?.[0];
event.currentTarget.value = '';
if (!file) {
return;
}
try {
const dataUrl = await readPuzzleReferenceImageAsDataUrl(file);
setReferenceImageSrc(dataUrl);
setReferenceImageLabel(file.name.trim() || '本地参考图');
setReferenceImageError(null);
} catch (uploadError) {
setReferenceImageError(
uploadError instanceof Error
? uploadError.message
: '参考图读取失败,请重试。',
);
}
};
if (typeof document === 'undefined') {
return null;
}
return createPortal(
{
if (event.target === event.currentTarget) {
onClose();
}
}}
>
event.stopPropagation()}
>
{level.levelName || '关卡详情'}
{hasFormalImage ? (
画面图
) : null}
画面描述
{referenceImageSrc ? (
{referenceImageLabel || '已选择参考图'}
) : null}
{referenceImageError ? (
{referenceImageError}
) : null}
{onStartTestRun && hasFormalImage ? (
) : null}
{isHistoryPickerOpen ? (
setIsHistoryPickerOpen(false)}
onSelect={(asset) => {
setReferenceImageSrc(asset.imageSrc);
setReferenceImageLabel(
`历史素材 · ${asset.ownerLabel || '未记录账号'}`,
);
setReferenceImageError(null);
setIsHistoryPickerOpen(false);
}}
/>
) : null}
,
document.body,
);
}
function PuzzlePublishDialog({
blockers,
editState,
imageRefreshKey,
isBusy,
publishReady,
onClose,
onPublish,
}: {
blockers: string[];
editState: DraftEditState;
imageRefreshKey: string;
isBusy: boolean;
publishReady: boolean;
onClose: () => void;
onPublish: () => void;
}) {
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
const primaryLevel = editState.levels[0] ?? null;
const formalImageSrc = primaryLevel ? resolveLevelFormalImageSrc(primaryLevel) : '';
if (typeof document === 'undefined') {
return null;
}
return createPortal(
{
if (event.target === event.currentTarget) {
onClose();
}
}}
>
event.stopPropagation()}
>
发布检查
{publishReady ? (
当前作品已满足发布条件。
) : (
{blockers.map((blocker, index) => (
{blocker}
))}
)}
封面关卡
{formalImageSrc ? (
) : null}
{editState.workTitle}
,
document.body,
);
}
function PuzzleLevelListTab({
editState,
imageRefreshKey,
isBusy,
onAddLevel,
onDeleteLevel,
onOpenLevel,
}: {
editState: DraftEditState;
imageRefreshKey: string;
isBusy: boolean;
onAddLevel: () => void;
onDeleteLevel: (levelId: string) => void;
onOpenLevel: (levelId: string) => void;
}) {
return (
{editState.levels.map((level, index) => {
const imageSrc = resolveLevelFormalImageSrc(level);
const displayLevelName = level.levelName || `第${index + 1}关`;
return (
);
})}
);
}
function PuzzleWorkInfoTab({
editState,
isBusy,
onChange,
}: {
editState: DraftEditState;
isBusy: boolean;
onChange: (nextState: DraftEditState) => void;
}) {
return (
);
}
function PuzzleResultActionBar({
editState,
imageRefreshKey,
isBusy,
publishReady,
publishBlockers,
onPublish,
}: {
editState: DraftEditState;
imageRefreshKey: string;
isBusy: boolean;
publishReady: boolean;
publishBlockers: string[];
onPublish: () => void;
}) {
const [showPublishDialog, setShowPublishDialog] = useState(false);
return (
{showPublishDialog ? (
setShowPublishDialog(false)}
onPublish={onPublish}
/>
) : null}
);
}
export function PuzzleResultView({
session,
profileId = null,
isBusy = false,
error = null,
onBack,
onExecuteAction,
onStartTestRun,
}: PuzzleResultViewProps) {
const draft = session.draft;
const [activeTab, setActiveTab] = useState('levels');
const [activeLevelId, setActiveLevelId] = useState(null);
const [editState, setEditState] = useState(
draft ? createDraftEditState(draft) : null,
);
const [autoSaveState, setAutoSaveState] =
useState('idle');
const [autoSaveError, setAutoSaveError] = useState(null);
const savedEditStateRef = useRef(
draft ? createDraftEditState(draft) : null,
);
useEffect(() => {
if (!draft) {
setEditState(null);
setActiveLevelId(null);
setAutoSaveState('idle');
setAutoSaveError(null);
return;
}
const nextState = createDraftEditState(draft);
savedEditStateRef.current = nextState;
setEditState(nextState);
setActiveLevelId((currentLevelId) =>
currentLevelId &&
nextState.levels.some((level) => level.levelId === currentLevelId)
? currentLevelId
: null,
);
setAutoSaveState('idle');
setAutoSaveError(null);
}, [draft]);
const syncedDraft = useMemo(() => {
if (!draft || !editState) {
return null;
}
return syncDraftFromEditState(draft, editState);
}, [draft, editState]);
const primaryLevel = editState?.levels[0] ?? null;
const primaryImageSrc = primaryLevel ? resolveLevelFormalImageSrc(primaryLevel) : '';
const imageRefreshKey = `${session.updatedAt}:${primaryImageSrc}:${editState?.levels.length ?? 0}`;
const activeLevel =
editState?.levels.find((level) => level.levelId === activeLevelId) ?? null;
useEffect(() => {
if (!draft || !editState || !profileId) {
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(),
})),
};
const originalState = savedEditStateRef.current ?? createDraftEditState(draft);
const changed =
JSON.stringify(normalizedState) !== JSON.stringify(originalState);
if (!changed || normalizedState.levels.length <= 0) {
return;
}
setAutoSaveState('saving');
setAutoSaveError(null);
let cancelled = false;
const timer = window.setTimeout(() => {
const firstLevel = normalizedState.levels[0]!;
void updatePuzzleWork(profileId, {
workTitle: normalizedState.workTitle,
workDescription: normalizedState.workDescription,
levelName: firstLevel.levelName,
summary: firstLevel.pictureDescription,
themeTags: normalizedState.themeTags,
coverImageSrc: resolveLevelFormalImageSrc(firstLevel) || null,
coverAssetId: firstLevel.coverAssetId ?? null,
levels: normalizedState.levels,
})
.then(() => {
if (cancelled) {
return;
}
// 自动保存成功后推进比较基线,避免新增后再删除回首版形态时漏同步后端。
savedEditStateRef.current = normalizedState;
setAutoSaveState('saved');
})
.catch((saveError) => {
if (cancelled) {
return;
}
setAutoSaveState('error');
setAutoSaveError(
saveError instanceof Error ? saveError.message : '自动保存失败。',
);
});
}, PUZZLE_AUTOSAVE_DEBOUNCE_MS);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [draft, editState, profileId]);
const publishState = useMemo(() => {
if (!editState) {
return {
blockers: ['等待结果页草稿完成后再发布。'],
publishReady: false,
};
}
return buildPublishReady(session, editState);
}, [editState, session]);
if (!draft || !editState || !syncedDraft) {
return (
);
}
const updateLevel = (nextLevel: PuzzleDraftLevel) => {
setEditState((currentState) =>
currentState
? {
...currentState,
levels: currentState.levels.map((level) =>
level.levelId === nextLevel.levelId ? nextLevel : level,
),
}
: currentState,
);
};
const buildLevelDraft = (level: PuzzleDraftLevel): PuzzleResultDraft => ({
...syncedDraft,
levelName: level.levelName,
summary: level.pictureDescription,
candidates: level.candidates,
selectedCandidateId: level.selectedCandidateId,
coverImageSrc: resolveLevelFormalImageSrc(level) || level.coverImageSrc,
coverAssetId: level.coverAssetId,
generationStatus: level.generationStatus,
levels: [level],
});
return (
{activeTab === 'levels' ? (
{
const nextLevel = createBlankPuzzleLevel(editState.levels);
setEditState({
...editState,
levels: [...editState.levels, nextLevel],
});
setActiveLevelId(nextLevel.levelId);
}}
onDeleteLevel={(levelId) => {
if (editState.levels.length <= 1) {
return;
}
const nextLevels = editState.levels.filter(
(level) => level.levelId !== levelId,
);
setEditState({
...editState,
levels: nextLevels,
});
if (activeLevelId === levelId) {
setActiveLevelId(null);
}
}}
onOpenLevel={setActiveLevelId}
/>
) : (
)}
{error ? (
{error}
) : null}
{!error && autoSaveError ? (
{autoSaveError}
) : null}
{
if (!publishState.publishReady) {
return;
}
const firstLevel = editState.levels[0]!;
onExecuteAction({
action: 'publish_puzzle_work',
workTitle: editState.workTitle.trim(),
workDescription: editState.workDescription.trim(),
levelName: firstLevel.levelName.trim(),
summary: firstLevel.pictureDescription.trim(),
themeTags: editState.themeTags,
levelsJson: JSON.stringify(editState.levels),
});
}}
/>
{activeLevel ? (
setActiveLevelId(null)}
onGenerate={(levelId, promptText, referenceImageSrc) => {
onExecuteAction({
action: 'generate_puzzle_images',
levelId,
promptText,
referenceImageSrc,
candidateCount: 1,
levelsJson: JSON.stringify(editState.levels),
});
}}
onLevelChange={updateLevel}
onStartTestRun={
onStartTestRun
? (level) => onStartTestRun(buildLevelDraft(level))
: undefined
}
/>
) : null}
);
}
export default PuzzleResultView;