@@ -15,6 +15,7 @@ import { createPortal } from 'react-dom';
|
||||
import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions';
|
||||
import type { 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,
|
||||
@@ -24,6 +25,7 @@ import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
|
||||
type PuzzleResultViewProps = {
|
||||
session: PuzzleAgentSessionSnapshot;
|
||||
profileId?: string | null;
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
@@ -32,6 +34,7 @@ type PuzzleResultViewProps = {
|
||||
};
|
||||
|
||||
type PuzzleResultTab = 'basic' | 'images';
|
||||
type PuzzleAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||
|
||||
type DraftEditState = {
|
||||
levelName: string;
|
||||
@@ -39,6 +42,10 @@ type DraftEditState = {
|
||||
themeTags: string[];
|
||||
};
|
||||
|
||||
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(
|
||||
@@ -84,7 +91,16 @@ function publishBlockedReason(session: PuzzleAgentSessionSnapshot) {
|
||||
if (!session.resultPreview) {
|
||||
return ['等待结果页草稿完成后再发布。'];
|
||||
}
|
||||
return session.resultPreview.blockers.map((entry) => entry.message);
|
||||
return session.resultPreview.blockers
|
||||
.filter(
|
||||
(entry) =>
|
||||
![
|
||||
'MISSING_LEVEL_NAME',
|
||||
'INVALID_TAG_COUNT',
|
||||
'MISSING_COVER_IMAGE',
|
||||
].includes(entry.code),
|
||||
)
|
||||
.map((entry) => entry.message);
|
||||
}
|
||||
|
||||
function buildPublishReady(
|
||||
@@ -96,7 +112,10 @@ function buildPublishReady(
|
||||
const blockers = [
|
||||
...publishBlockedReason(session),
|
||||
...(editState.levelName.trim() ? [] : ['关卡名不能为空。']),
|
||||
...(editState.themeTags.length > 0 ? [] : ['至少需要 1 个题材标签。']),
|
||||
...(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} 之间。`]),
|
||||
...(formalImageSrc ? [] : ['请先选择一张正式拼图图片。']),
|
||||
];
|
||||
|
||||
@@ -105,7 +124,8 @@ function buildPublishReady(
|
||||
publishReady:
|
||||
Boolean(session.resultPreview?.publishReady) &&
|
||||
Boolean(editState.levelName.trim()) &&
|
||||
editState.themeTags.length > 0 &&
|
||||
editState.themeTags.length >= PUZZLE_MIN_THEME_TAG_COUNT &&
|
||||
editState.themeTags.length <= PUZZLE_MAX_THEME_TAG_COUNT &&
|
||||
Boolean(formalImageSrc),
|
||||
};
|
||||
}
|
||||
@@ -130,12 +150,29 @@ function resolvePuzzleFormalImageSrc(draft: PuzzleResultDraft) {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -149,6 +186,7 @@ function PuzzleResultHeader({
|
||||
返回
|
||||
</span>
|
||||
</button>
|
||||
{autoSaveBadge}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -871,6 +909,7 @@ function PuzzleResultActionBar({
|
||||
*/
|
||||
export function PuzzleResultView({
|
||||
session,
|
||||
profileId = null,
|
||||
isBusy = false,
|
||||
error = null,
|
||||
onBack,
|
||||
@@ -884,15 +923,77 @@ export function PuzzleResultView({
|
||||
const [editState, setEditState] = useState<DraftEditState | null>(
|
||||
draft ? createDraftEditState(draft) : null,
|
||||
);
|
||||
const [autoSaveState, setAutoSaveState] = useState<PuzzleAutoSaveState>('idle');
|
||||
const [autoSaveError, setAutoSaveError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draft) {
|
||||
setEditState(null);
|
||||
setAutoSaveState('idle');
|
||||
setAutoSaveError(null);
|
||||
return;
|
||||
}
|
||||
setEditState(createDraftEditState(draft));
|
||||
setAutoSaveState('idle');
|
||||
setAutoSaveError(null);
|
||||
}, [draft]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draft || !editState || !profileId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedLevelName = editState.levelName.trim();
|
||||
const normalizedSummary = editState.summary.trim();
|
||||
const normalizedTags = normalizeThemeTagInput(editState.themeTags.join(','));
|
||||
const draftLevelName = draft.levelName.trim();
|
||||
const draftSummary = draft.summary.trim();
|
||||
const draftTags = normalizeThemeTagInput(draft.themeTags.join(','));
|
||||
const levelNameChanged = normalizedLevelName !== draftLevelName;
|
||||
const summaryChanged = normalizedSummary !== draftSummary;
|
||||
const tagsChanged =
|
||||
normalizedTags.length !== draftTags.length ||
|
||||
normalizedTags.some((tag, index) => tag !== draftTags[index]);
|
||||
|
||||
if (!levelNameChanged && !summaryChanged && !tagsChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAutoSaveState('saving');
|
||||
setAutoSaveError(null);
|
||||
|
||||
let cancelled = false;
|
||||
const timer = window.setTimeout(() => {
|
||||
void updatePuzzleWork(profileId, {
|
||||
levelName: normalizedLevelName,
|
||||
summary: normalizedSummary,
|
||||
themeTags: normalizedTags,
|
||||
coverImageSrc: formalImageSrc || null,
|
||||
coverAssetId: draft.coverAssetId ?? null,
|
||||
})
|
||||
.then(() => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
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, formalImageSrc, profileId]);
|
||||
|
||||
const publishState = useMemo(() => {
|
||||
if (!draft || !editState) {
|
||||
return {
|
||||
@@ -915,7 +1016,11 @@ export function PuzzleResultView({
|
||||
|
||||
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 isBusy={isBusy} onBack={onBack} />
|
||||
<PuzzleResultHeader
|
||||
autoSaveState={autoSaveState}
|
||||
isBusy={isBusy}
|
||||
onBack={onBack}
|
||||
/>
|
||||
|
||||
<PuzzleResultTabs
|
||||
activeTab={activeTab}
|
||||
@@ -953,6 +1058,11 @@ export function PuzzleResultView({
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
{!error && autoSaveError ? (
|
||||
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
|
||||
{autoSaveError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<PuzzleResultActionBar
|
||||
draft={draft}
|
||||
|
||||
Reference in New Issue
Block a user