Preserve partial creation replies on stream failure
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
580
src/components/square-hole-result/SquareHoleResultView.tsx
Normal file
580
src/components/square-hole-result/SquareHoleResultView.tsx
Normal 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;
|
||||
1
src/components/square-hole-result/index.ts
Normal file
1
src/components/square-hole-result/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { SquareHoleResultView } from './SquareHoleResultView';
|
||||
Reference in New Issue
Block a user