1412 lines
47 KiB
TypeScript
1412 lines
47 KiB
TypeScript
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;
|