Files
Genarrative/src/components/puzzle-result/PuzzleResultView.tsx
2026-05-01 01:30:02 +08:00

1412 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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' ? (
<div className="platform-pill platform-pill--warm px-3 py-1 text-[11px]">
</div>
) : autoSaveState === 'saved' ? (
<div className="platform-pill platform-pill--success px-3 py-1 text-[11px]">
</div>
) : autoSaveState === 'error' ? (
<div className="platform-pill platform-pill--rose px-3 py-1 text-[11px]">
</div>
) : null;
return (
<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] ${isBusy ? 'opacity-45' : ''}`}
>
<span className="inline-flex items-center gap-1.5">
<ArrowLeft className="h-3.5 w-3.5" />
</span>
</button>
{autoSaveBadge}
</div>
);
}
function PuzzleResultTabs({
activeTab,
onChange,
}: {
activeTab: PuzzleResultTab;
onChange: (tab: PuzzleResultTab) => void;
}) {
return (
<div className="mb-3 grid grid-cols-2 gap-2 rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/62 p-1">
{[
{ id: 'levels' as const, label: '拼图关卡' },
{ id: 'work' as const, label: '作品信息' },
].map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => onChange(tab.id)}
className={`min-h-10 rounded-[1rem] px-3 text-sm font-bold transition ${
activeTab === tab.id
? 'bg-white text-[var(--platform-text-strong)] shadow-sm'
: 'text-[var(--platform-text-base)] hover:bg-white/60'
}`}
aria-pressed={activeTab === tab.id}
>
{tab.label}
</button>
))}
</div>
);
}
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 (
<section className="platform-subpanel rounded-[1.35rem] p-4 sm:p-5">
<div className="flex items-center justify-between gap-3">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
{!isAddingTag ? (
<button
type="button"
disabled={isBusy}
onClick={() => setIsAddingTag(true)}
className="platform-icon-button h-9 w-9"
aria-label="新增作品标签"
title="新增作品标签"
>
<Plus className="h-4 w-4" />
</button>
) : null}
</div>
<div className="mt-3 flex flex-wrap gap-2">
{editState.themeTags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1.5 rounded-full border border-amber-300/35 bg-amber-100/68 px-3 py-1.5 text-xs font-semibold text-amber-700"
>
{tag}
<button
type="button"
disabled={isBusy}
onClick={() => {
onChange({
...editState,
themeTags: editState.themeTags.filter(
(currentTag) => currentTag !== tag,
),
});
}}
className="rounded-full text-amber-800/70 transition hover:text-amber-950 disabled:opacity-45"
aria-label={`删除标签 ${tag}`}
title="删除标签"
>
<X className="h-3.5 w-3.5" />
</button>
</span>
))}
{editState.themeTags.length <= 0 ? (
<span className="text-sm text-[var(--platform-text-soft)]">
</span>
) : null}
</div>
{isAddingTag ? (
<div className="mt-3 flex flex-col gap-2 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/68 p-2 sm:flex-row">
<input
autoFocus
value={newTagText}
disabled={isBusy}
onChange={(event) => setNewTagText(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
addTags();
}
if (event.key === 'Escape') {
setIsAddingTag(false);
setNewTagText('');
}
}}
className="min-h-10 flex-1 rounded-[0.8rem] border border-transparent bg-white/92 px-3 text-sm text-[var(--platform-text-strong)] outline-none"
placeholder="输入新标签"
aria-label="新题材标签"
/>
<div className="flex gap-2">
<button
type="button"
disabled={isBusy}
onClick={addTags}
className="platform-button platform-button--primary min-h-10 flex-1 px-4 py-2 text-sm sm:flex-none"
>
</button>
<button
type="button"
disabled={isBusy}
onClick={() => {
setIsAddingTag(false);
setNewTagText('');
}}
className="platform-button platform-button--ghost min-h-10 flex-1 px-4 py-2 text-sm sm:flex-none"
>
</button>
</div>
</div>
) : null}
</section>
);
}
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,
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<string | null>(null);
const [isHistoryPickerOpen, setIsHistoryPickerOpen] = useState(false);
const formalImageSrc = resolveLevelFormalImageSrc(level);
const hasFormalImage = Boolean(formalImageSrc);
const handleReferenceImageChange = async (
event: ChangeEvent<HTMLInputElement>,
) => {
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(
<div
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[138] 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(94vh,50rem)] w-full max-w-2xl 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="min-w-0 truncate text-base font-semibold text-[var(--platform-text-strong)]">
{level.levelName || '关卡详情'}
</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">
<div className="space-y-4">
<section className="platform-subpanel rounded-[1.35rem] p-4">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<input
value={level.levelName}
disabled={isBusy}
onChange={(event) =>
onLevelChange({ ...level, levelName: event.target.value })
}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base font-semibold text-[var(--platform-text-strong)] outline-none"
aria-label="关卡名称"
/>
</section>
{hasFormalImage ? (
<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 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="画面描述"
/>
<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 ? '更换参考图' : '添加参考图'}
>
<ImagePlus className="h-4 w-4" />
<span className="sr-only">
{referenceImageSrc ? '更换参考图' : '添加参考图'}
</span>
<input
type="file"
accept="image/png,image/jpeg,image/webp"
disabled={isBusy}
onChange={(event) => {
void handleReferenceImageChange(event);
}}
className="hidden"
/>
</label>
</div>
{referenceImageSrc ? (
<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)]">
<img
src={referenceImageSrc}
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);
}}
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>
</div>
</div>
<div className="shrink-0 space-y-3 border-t border-[var(--platform-subpanel-border)] bg-[var(--platform-page-fill)] px-5 py-4 pb-[calc(env(safe-area-inset-bottom,0px)+1rem)]">
{onStartTestRun && hasFormalImage ? (
<button
type="button"
disabled={isBusy}
onClick={() => onStartTestRun(level)}
className={`platform-button platform-button--secondary w-full ${isBusy ? 'opacity-55' : ''}`}
>
<span className="inline-flex items-center gap-2">
<Play className="h-4 w-4" />
</span>
</button>
) : null}
<button
type="button"
disabled={isBusy}
onClick={() => {
onGenerate(
level.levelId,
level.pictureDescription.trim() || undefined,
referenceImageSrc || undefined,
);
}}
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"
>
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
<Sparkles className="h-4 w-4" />
{hasFormalImage ? '重新生成画面' : '生成画面'}
</button>
</div>
{isHistoryPickerOpen ? (
<PuzzleHistoryAssetPickerDialog
isBusy={isBusy}
onClose={() => setIsHistoryPickerOpen(false)}
onSelect={(asset) => {
setReferenceImageSrc(asset.imageSrc);
setReferenceImageLabel(
`历史素材 · ${asset.ownerLabel || '未记录账号'}`,
);
setReferenceImageError(null);
setIsHistoryPickerOpen(false);
}}
/>
) : null}
</div>
</div>,
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(
<div
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[140] 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(90vh,42rem)] w-full max-w-3xl 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">
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_14rem]">
<div className="space-y-3">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
{publishReady ? (
<div className="platform-banner platform-banner--success text-sm leading-6">
</div>
) : (
<div className="space-y-2">
{blockers.map((blocker, index) => (
<div
key={`puzzle-publish-blocker-${index}-${blocker}`}
className="platform-banner platform-banner--warning text-sm leading-6"
>
{blocker}
</div>
))}
</div>
)}
</div>
<div className="space-y-3">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<div className="aspect-square overflow-hidden rounded-[1.15rem] bg-[var(--platform-subpanel-fill)]">
{formalImageSrc ? (
<ResolvedAssetImage
src={formalImageSrc}
refreshKey={imageRefreshKey}
alt={primaryLevel?.levelName || editState.workTitle}
className="h-full w-full object-cover"
/>
) : null}
</div>
<div className="text-sm font-black text-[var(--platform-text-strong)]">
{editState.workTitle}
</div>
</div>
</div>
</div>
<div className="flex flex-col-reverse gap-3 border-t border-[var(--platform-subpanel-border)] px-5 py-4 sm:flex-row sm:justify-end">
<button
type="button"
onClick={onClose}
className="platform-button platform-button--ghost"
>
</button>
<button
type="button"
onClick={onPublish}
disabled={!publishReady || isBusy}
className={`platform-button platform-button--primary ${!publishReady || isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
>
{isBusy ? '发布中...' : '发布到广场'}
</button>
</div>
</div>
</div>,
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 (
<div className="space-y-3">
<div 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}`;
return (
<div
key={level.levelId}
className="platform-subpanel overflow-hidden rounded-[1.35rem] p-0"
>
<button
type="button"
onClick={() => onOpenLevel(level.levelId)}
className="block w-full text-left"
>
<div className="aspect-[4/3] overflow-hidden bg-[var(--platform-subpanel-fill)]">
{imageSrc ? (
<ResolvedAssetImage
src={imageSrc}
refreshKey={`${imageRefreshKey}:${level.levelId}`}
alt={displayLevelName}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full items-center justify-center text-sm text-[var(--platform-text-soft)]">
</div>
)}
</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>
</div>
</button>
<div className="flex items-end gap-2 px-4 pb-4">
<button
type="button"
onClick={() => onOpenLevel(level.levelId)}
className="min-w-0 flex-1 truncate text-left text-base font-black text-[var(--platform-text-strong)]"
>
{displayLevelName}
</button>
<button
type="button"
disabled={isBusy || editState.levels.length <= 1}
onClick={() => onDeleteLevel(level.levelId)}
className="platform-icon-button h-9 w-9 shrink-0"
aria-label={`删除关卡 ${displayLevelName}`}
title="删除关卡"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
);
})}
</div>
<button
type="button"
disabled={isBusy}
onClick={onAddLevel}
className="platform-button platform-button--secondary w-full"
>
<span className="inline-flex items-center gap-2">
<Plus className="h-4 w-4" />
</span>
<span className="mt-1 block text-[11px] font-semibold leading-none text-[var(--platform-text-soft)]">
</span>
</button>
</div>
);
}
function PuzzleWorkInfoTab({
editState,
isBusy,
onChange,
}: {
editState: DraftEditState;
isBusy: boolean;
onChange: (nextState: DraftEditState) => void;
}) {
return (
<div className="space-y-3">
<section className="platform-subpanel rounded-[1.35rem] p-4 sm:p-5">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<input
value={editState.workTitle}
disabled={isBusy}
onChange={(event) =>
onChange({ ...editState, workTitle: event.target.value })
}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-base font-semibold text-[var(--platform-text-strong)] outline-none"
aria-label="作品名称"
/>
</section>
<section className="platform-subpanel rounded-[1.35rem] p-4 sm:p-5">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<textarea
value={editState.workDescription}
disabled={isBusy}
rows={6}
onChange={(event) =>
onChange({ ...editState, workDescription: event.target.value })
}
className="mt-3 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
aria-label="作品描述"
/>
</section>
<PuzzleThemeTagEditor
editState={editState}
isBusy={isBusy}
onChange={onChange}
/>
</div>
);
}
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 (
<div className="mt-4 flex items-center justify-end gap-3 pb-[max(0.25rem,env(safe-area-inset-bottom))]">
<button
type="button"
onClick={() => setShowPublishDialog(true)}
disabled={isBusy}
className={`platform-button platform-button--primary ${isBusy ? 'opacity-55' : ''}`}
>
<span className="inline-flex items-center gap-2">
<CheckCircle2 className="h-4 w-4" />
</span>
</button>
{showPublishDialog ? (
<PuzzlePublishDialog
blockers={publishBlockers}
editState={editState}
imageRefreshKey={imageRefreshKey}
isBusy={isBusy}
publishReady={publishReady}
onClose={() => setShowPublishDialog(false)}
onPublish={onPublish}
/>
) : null}
</div>
);
}
export function PuzzleResultView({
session,
profileId = null,
isBusy = false,
error = null,
onBack,
onExecuteAction,
onStartTestRun,
}: PuzzleResultViewProps) {
const draft = session.draft;
const [activeTab, setActiveTab] = useState<PuzzleResultTab>('levels');
const [activeLevelId, setActiveLevelId] = useState<string | null>(null);
const [editState, setEditState] = useState<DraftEditState | null>(
draft ? createDraftEditState(draft) : null,
);
const [autoSaveState, setAutoSaveState] =
useState<PuzzleAutoSaveState>('idle');
const [autoSaveError, setAutoSaveError] = useState<string | null>(null);
const savedEditStateRef = useRef<DraftEditState | null>(
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 (
<div className="flex h-full items-center justify-center">
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-[var(--platform-text-base)]">
稿
</div>
</div>
);
}
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 (
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full flex-col xl:max-w-[min(100%,98rem)] xl:px-1 2xl:max-w-[min(100%,112rem)]">
<PuzzleResultHeader
autoSaveState={autoSaveState}
isBusy={isBusy}
onBack={onBack}
/>
<PuzzleResultTabs activeTab={activeTab} onChange={setActiveTab} />
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
{activeTab === 'levels' ? (
<PuzzleLevelListTab
editState={editState}
imageRefreshKey={imageRefreshKey}
isBusy={isBusy}
onAddLevel={() => {
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}
/>
) : (
<PuzzleWorkInfoTab
editState={editState}
isBusy={isBusy}
onChange={setEditState}
/>
)}
</div>
{error ? (
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
{error}
</div>
) : null}
{!error && autoSaveError ? (
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
{autoSaveError}
</div>
) : null}
<PuzzleResultActionBar
editState={editState}
imageRefreshKey={imageRefreshKey}
isBusy={isBusy}
publishReady={publishState.publishReady}
publishBlockers={publishState.blockers}
onPublish={() => {
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 ? (
<PuzzleLevelDetailDialog
draft={syncedDraft}
imageRefreshKey={imageRefreshKey}
isBusy={isBusy}
level={activeLevel}
onClose={() => 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}
</div>
);
}
export default PuzzleResultView;