Preserve partial creation replies on stream failure
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
kdletters
2026-05-05 11:31:50 +08:00
parent 100fee7e7a
commit 995661e7cc
299 changed files with 13805 additions and 1429 deletions

View File

@@ -0,0 +1,580 @@
import {
ArrowLeft,
CheckCircle2,
ImagePlus,
Loader2,
Play,
Send,
} from 'lucide-react';
import { type ChangeEvent, useEffect, useMemo, useState } from 'react';
import type { SquareHoleResultDraft } from '../../../packages/shared/src/contracts/squareHoleAgent';
import type {
PutSquareHoleWorkRequest,
SquareHoleWorkProfile,
} from '../../../packages/shared/src/contracts/squareHoleWorks';
import {
publishSquareHoleWork,
updateSquareHoleWork,
} from '../../services/square-hole-works';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
type SquareHoleResultViewProps = {
profile: SquareHoleWorkProfile;
draft?: SquareHoleResultDraft | null;
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onSaved?: (profile: SquareHoleWorkProfile) => void;
onPublished?: (profile: SquareHoleWorkProfile) => void;
onStartTestRun: (profile: SquareHoleWorkProfile) => void;
};
type SquareHoleAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
type SquareHoleResultEditState = {
gameName: string;
summary: string;
tagsText: string;
coverImageSrc: string;
themeText: string;
twistRule: string;
shapeCountText: string;
difficultyText: string;
};
const SQUARE_HOLE_AUTOSAVE_DEBOUNCE_MS = 600;
function normalizeTags(value: string) {
return [
...new Set(
value
.split(/[\n,]/u)
.map((entry) => entry.trim())
.filter(Boolean),
),
];
}
function normalizeShapeCount(value: string) {
const parsed = Number.parseInt(value.trim(), 10);
return Number.isFinite(parsed) && parsed >= 6 && parsed <= 24
? parsed
: null;
}
function normalizeDifficulty(value: string) {
const parsed = Number.parseInt(value.trim(), 10);
return Number.isFinite(parsed) && parsed >= 1 && parsed <= 10
? parsed
: null;
}
function createEditState(
profile: SquareHoleWorkProfile,
): SquareHoleResultEditState {
return {
gameName: profile.gameName,
summary: profile.summary,
tagsText: profile.tags.join(''),
coverImageSrc: profile.coverImageSrc?.trim() || '',
themeText: profile.themeText,
twistRule: profile.twistRule,
shapeCountText: String(profile.shapeCount),
difficultyText: String(profile.difficulty),
};
}
function buildSavePayload(
editState: SquareHoleResultEditState,
): PutSquareHoleWorkRequest | null {
const shapeCount = normalizeShapeCount(editState.shapeCountText);
const difficulty = normalizeDifficulty(editState.difficultyText);
const gameName = editState.gameName.trim();
const themeText = editState.themeText.trim();
const twistRule = editState.twistRule.trim();
const summary = editState.summary.trim();
const tags = normalizeTags(editState.tagsText);
if (
!gameName ||
!themeText ||
!twistRule ||
!summary ||
tags.length === 0 ||
!shapeCount ||
!difficulty
) {
return null;
}
return {
gameName,
themeText,
twistRule,
summary,
tags,
coverImageSrc: editState.coverImageSrc.trim() || null,
shapeCount,
difficulty,
};
}
function buildPublishBlockers(editState: SquareHoleResultEditState) {
const blockers = [
...(editState.gameName.trim() ? [] : ['游戏名称不能为空。']),
...(editState.themeText.trim() ? [] : ['题材主题不能为空。']),
...(editState.twistRule.trim() ? [] : ['反差规则不能为空。']),
...(editState.summary.trim() ? [] : ['简介不能为空。']),
...(normalizeTags(editState.tagsText).length > 0
? []
: ['至少需要 1 个标签。']),
...(normalizeShapeCount(editState.shapeCountText)
? []
: ['形状数量需要在 6 到 24 之间。']),
...(normalizeDifficulty(editState.difficultyText)
? []
: ['难度必须为 1 到 10。']),
];
return [...new Set(blockers)];
}
function readImageAsDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
if (!file.type.startsWith('image/')) {
reject(new Error('请选择图片文件。'));
return;
}
const reader = new FileReader();
reader.onerror = () => reject(new Error('封面图读取失败,请重试。'));
reader.onload = () => resolve(String(reader.result || ''));
reader.readAsDataURL(file);
});
}
function buildPlayableProfile(
profile: SquareHoleWorkProfile,
editState: SquareHoleResultEditState,
) {
const payload = buildSavePayload(editState);
if (!payload) {
return profile;
}
return {
...profile,
gameName: payload.gameName,
themeText: payload.themeText ?? profile.themeText,
twistRule: payload.twistRule,
summary: payload.summary,
tags: payload.tags,
coverImageSrc: payload.coverImageSrc,
shapeCount: payload.shapeCount,
difficulty: payload.difficulty,
};
}
function SquareHoleResultHeader({
autoSaveState,
isBusy,
onBack,
}: {
autoSaveState: SquareHoleAutoSaveState;
isBusy: boolean;
onBack: () => void;
}) {
const badge =
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>
{badge}
</div>
);
}
export function SquareHoleResultView({
profile,
draft = null,
isBusy = false,
error = null,
onBack,
onSaved,
onPublished,
onStartTestRun,
}: SquareHoleResultViewProps) {
const [editState, setEditState] = useState(() => createEditState(profile));
const [autoSaveState, setAutoSaveState] =
useState<SquareHoleAutoSaveState>('idle');
const [localError, setLocalError] = useState<string | null>(null);
const [isPublishing, setIsPublishing] = useState(false);
const [isStartingTestRun, setIsStartingTestRun] = useState(false);
const blockers = useMemo(() => buildPublishBlockers(editState), [editState]);
const canSubmit = blockers.length === 0;
useEffect(() => {
setEditState(createEditState(profile));
setAutoSaveState('idle');
setLocalError(null);
}, [profile.profileId, profile.updatedAt]);
useEffect(() => {
const payload = buildSavePayload(editState);
if (!payload) {
return undefined;
}
const currentTags = normalizeTags(profile.tags.join(''));
const changed =
payload.gameName !== profile.gameName ||
payload.themeText !== profile.themeText ||
payload.twistRule !== profile.twistRule ||
payload.summary !== profile.summary ||
(payload.coverImageSrc ?? '') !== (profile.coverImageSrc ?? '') ||
payload.shapeCount !== profile.shapeCount ||
payload.difficulty !== profile.difficulty ||
payload.tags.length !== currentTags.length ||
payload.tags.some((tag, index) => tag !== currentTags[index]);
if (!changed) {
return undefined;
}
setAutoSaveState('saving');
setLocalError(null);
let cancelled = false;
const timer = window.setTimeout(() => {
void updateSquareHoleWork(profile.profileId, payload)
.then(({ item }) => {
if (cancelled) {
return;
}
setAutoSaveState('saved');
onSaved?.(item);
})
.catch((saveError) => {
if (cancelled) {
return;
}
setAutoSaveState('error');
setLocalError(
saveError instanceof Error ? saveError.message : '自动保存失败。',
);
});
}, SQUARE_HOLE_AUTOSAVE_DEBOUNCE_MS);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [editState, onSaved, profile]);
const saveNow = async () => {
const payload = buildSavePayload(editState);
if (!payload) {
setLocalError(blockers[0] ?? '请补全作品信息。');
return null;
}
setAutoSaveState('saving');
setLocalError(null);
const { item } = await updateSquareHoleWork(profile.profileId, payload);
setAutoSaveState('saved');
onSaved?.(item);
return item;
};
const handleCoverImageChange = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0] ?? null;
event.target.value = '';
if (!file) {
return;
}
try {
const dataUrl = await readImageAsDataUrl(file);
setEditState((current) => ({
...current,
coverImageSrc: dataUrl,
}));
setLocalError(null);
} catch (caughtError) {
setLocalError(
caughtError instanceof Error ? caughtError.message : '封面图读取失败。',
);
}
};
const handleStartTestRun = async () => {
if (!canSubmit || isStartingTestRun) {
setLocalError(blockers[0] ?? null);
return;
}
setIsStartingTestRun(true);
try {
const savedProfile = await saveNow();
onStartTestRun(savedProfile ?? buildPlayableProfile(profile, editState));
} catch (caughtError) {
setLocalError(
caughtError instanceof Error ? caughtError.message : '启动试玩前保存失败。',
);
} finally {
setIsStartingTestRun(false);
}
};
const handlePublish = async () => {
if (!canSubmit || isPublishing) {
setLocalError(blockers[0] ?? null);
return;
}
setIsPublishing(true);
try {
const savedProfile = await saveNow();
const { item } = await publishSquareHoleWork(
savedProfile?.profileId ?? profile.profileId,
);
onPublished?.(item);
setLocalError(null);
} catch (caughtError) {
setLocalError(
caughtError instanceof Error ? caughtError.message : '发布方洞挑战失败。',
);
} finally {
setIsPublishing(false);
}
};
const busy = isBusy || isPublishing || isStartingTestRun;
const displayError = error ?? localError;
return (
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full flex-col xl:max-w-[min(100%,88rem)]">
<SquareHoleResultHeader
autoSaveState={autoSaveState}
isBusy={busy}
onBack={onBack}
/>
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
<div className="grid gap-3 lg:grid-cols-[minmax(17rem,0.72fr)_minmax(0,1fr)]">
<section className="platform-subpanel rounded-[1.5rem] p-4 sm:p-5">
<div className="aspect-[4/3] overflow-hidden rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-[radial-gradient(circle_at_42%_30%,rgba(125,211,252,0.28),transparent_34%),linear-gradient(135deg,rgba(15,23,42,0.16),rgba(20,184,166,0.18))]">
{editState.coverImageSrc ? (
<ResolvedAssetImage
src={editState.coverImageSrc}
alt=""
aria-hidden="true"
className="h-full w-full object-cover"
/>
) : (
<div className="grid h-full w-full place-items-center text-slate-700">
<ImagePlus className="h-10 w-10" />
</div>
)}
</div>
<label className="platform-button platform-button--ghost mt-3 flex min-h-10 cursor-pointer items-center justify-center gap-2 px-3 py-2 text-sm">
<ImagePlus className="h-4 w-4" />
<input
type="file"
accept="image/*"
className="sr-only"
disabled={busy}
onChange={handleCoverImageChange}
/>
</label>
<div className="mt-3 grid grid-cols-3 gap-2 text-center text-xs font-bold text-[var(--platform-text-base)]">
<div className="rounded-[1rem] bg-white/68 px-2 py-2">
{editState.shapeCountText || '-'}
</div>
<div className="rounded-[1rem] bg-white/68 px-2 py-2">
{editState.difficultyText || '-'}
</div>
<div className="rounded-[1rem] bg-white/68 px-2 py-2">
{draft?.publishReady ?? profile.publishReady ? '可发布' : '草稿'}
</div>
</div>
</section>
<section className="platform-subpanel rounded-[1.5rem] p-4 sm:p-5">
<div className="grid gap-3 sm:grid-cols-2">
<label className="block sm:col-span-2">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<input
value={editState.gameName}
disabled={busy}
onChange={(event) =>
setEditState({ ...editState, gameName: 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"
/>
</label>
<label className="block sm:col-span-2">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<input
value={editState.tagsText}
disabled={busy}
onChange={(event) =>
setEditState({ ...editState, tagsText: event.target.value })
}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
/>
</label>
<label className="block sm:col-span-2">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<textarea
value={editState.summary}
disabled={busy}
onChange={(event) =>
setEditState({ ...editState, summary: event.target.value })
}
rows={3}
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
/>
</label>
<label className="block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<input
value={editState.themeText}
disabled={busy}
onChange={(event) =>
setEditState({ ...editState, themeText: event.target.value })
}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
/>
</label>
<label className="block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<input
value={editState.twistRule}
disabled={busy}
onChange={(event) =>
setEditState({ ...editState, twistRule: event.target.value })
}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
/>
</label>
<label className="block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<input
value={editState.shapeCountText}
inputMode="numeric"
disabled={busy}
onChange={(event) =>
setEditState({
...editState,
shapeCountText: event.target.value,
})
}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
/>
</label>
<label className="block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<input
value={editState.difficultyText}
inputMode="numeric"
disabled={busy}
onChange={(event) =>
setEditState({
...editState,
difficultyText: event.target.value,
})
}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
/>
</label>
</div>
</section>
</div>
</div>
{displayError ? (
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
{displayError}
</div>
) : null}
<div className="mt-3 flex flex-col gap-2 pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:flex-row sm:justify-end">
<button
type="button"
onClick={handleStartTestRun}
disabled={!canSubmit || busy}
className={`platform-button platform-button--ghost min-h-11 justify-center gap-2 px-5 py-3 ${!canSubmit || busy ? 'cursor-not-allowed opacity-55' : ''}`}
>
{isStartingTestRun ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Play className="h-4 w-4" />
)}
</button>
<button
type="button"
onClick={handlePublish}
disabled={!canSubmit || busy}
className={`platform-button platform-button--primary min-h-11 justify-center gap-2 px-5 py-3 ${!canSubmit || busy ? 'cursor-not-allowed opacity-55' : ''}`}
>
{isPublishing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : profile.publicationStatus === 'published' ? (
<CheckCircle2 className="h-4 w-4" />
) : (
<Send className="h-4 w-4" />
)}
{profile.publicationStatus === 'published' ? '更新发布' : '发布'}
</button>
</div>
</div>
);
}
export default SquareHoleResultView;

View File

@@ -0,0 +1 @@
export { SquareHoleResultView } from './SquareHoleResultView';